Testing Guide
Overview
discord.go is designed for testability. The gateway connection, REST HTTP client, and command handlers can all be mocked or replaced in tests. This guide covers the patterns for testing bots built with the library.
Mocking the Gateway
The gateway Connection interface has three methods:
type Connection interface {
Read() ([]byte, error)
Write([]byte) error
Close() error
}Implement this in test code to simulate gateway events:
type testConnection struct {
messages [][]byte
idx int
writes [][]byte
}
func (t *testConnection) Read() ([]byte, error) {
if t.idx >= len(t.messages) {
return nil, errors.New("connection closed")
}
msg := t.messages[t.idx]
t.idx++
return msg, nil
}
func (t *testConnection) Write(data []byte) error {
t.writes = append(t.writes, data)
return nil
}
func (t *testConnection) Close() error { return nil }Inject it via bot.WithConnectionFactory:
conn := &testConnection{
messages: [][]byte{
// Simulate a READY event
[]byte(`{"op":0,"t":"READY","d":{"v":10,"user":{"id":"100","username":"testbot"}}}`),
},
}
bot := bot.New("test-token",
bot.WithIntents(intents.Guilds),
bot.WithConnectionFactory(func(url string) (gateway.Connection, error) {
return conn, nil
}),
)Mocking REST
Pass a custom http.Client to rest.New that returns canned responses:
import "net/http/httptest"
func newMockREST(t *testing.T, status int, response string) *rest.Client {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
w.Write([]byte(response))
}))
t.Cleanup(server.Close)
httpClient := &http.Client{Transport: &mockTransport{server: server}}
return rest.New("test-token", nil, httpClient)
}Or use httptest.Server directly to test REST call behavior:
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Assert request method, path, headers, body
if r.URL.Path != "/channels/123/messages" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
w.Write([]byte(`{"id":"456","content":"hello"}`))
}))
defer server.Close()Testing Command Handlers
Command handlers receive an *InteractionContext. Create one manually in tests:
func TestPingCommand(t *testing.T) {
router := bot.NewRouter()
called := false
router.Command("ping", "Check bot status", func(ctx *bot.InteractionContext) {
called = true
ctx.Reply("Pong!")
})
// Build a mock interaction
interaction := interactions.Interaction{
ID: "100",
Type: interactions.InteractionTypeApplicationCommand,
Data: json.RawMessage(`{"name":"ping"}`),
}
// Create context and invoke router
ctx := bot.NewTestInteractionContext(&interaction)
router.handleInteraction(ctx)
if !called {
t.Error("handler was not called")
}
}Testing Collectors
Use bot.AwaitInteraction with a context timeout in tests:
func TestButtonCollector(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
result, err := botClient.AwaitInteraction(ctx, func(ic *bot.InteractionContext) bool {
return ic.CustomID() == "confirm"
})
if err != nil {
t.Fatalf("await interaction: %v", err)
}
// Assert on result
}bot.AwaitReaction works the same way for reaction collectors:
func TestReactionCollector(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
result, err := botClient.AwaitReaction(ctx, func(rc *bot.ReactionContext) bool {
return rc.Emoji.Name == "✅"
})
if err != nil {
t.Fatalf("await reaction: %v", err)
}
// Assert on result
}Testing Interaction Servers
interactions.Server is an http.Handler and can be tested with httptest.NewRecorder:
func TestInteractionServer(t *testing.T) {
publicKey, privateKey, _ := ed25519.GenerateKey(nil)
publicKeyHex := hex.EncodeToString(publicKey)
srv := interactions.NewServer(publicKeyHex, func(i *interactions.Interaction) *interactions.InteractionResponse {
return &interactions.InteractionResponse{
Type: interactions.InteractionCallbackTypeChannelMessageWithSource,
Data: &interactions.InteractionCallbackData{Content: "Hello!"},
}
})
// Override time for deterministic replay tests:
srv.now = func() time.Time { return time.Unix(1700000000, 0) }
body := []byte(`{"type":2,"token":"test","version":1}`)
timestamp := "1700000000"
message := append([]byte(timestamp), body...)
signature := ed25519.Sign(privateKey, message)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Header.Set("X-Signature-Timestamp", timestamp)
req.Header.Set("X-Signature-Ed25519", hex.EncodeToString(signature))
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}Common Patterns
- Use
bot.WithConnectionFactoryto inject test connections. - Use
httptest.Serverto mock REST endpoints. - Create
InteractionContextmanually for unit testing handlers. - Use context timeouts for collector tests.
Best Practices
- Test the happy path and error cases.
- Assert on the HTTP method, path, headers, and body for REST calls.
- Test middleware in isolation by wrapping a test handler.
- Clean up test servers with
t.Cleanup.
Common Mistakes
- Not closing mock servers, causing port leaks.
- Using real tokens in tests. Always use test tokens.
- Not setting timeouts on collector tests, causing hangs.
- Testing the library's internals instead of your bot's behavior.