Answer Overflow uses Vitest for unit and integration tests, and a custom E2E framework built with Effect for Discord bot testing.
Testing Stack
- Vitest: Unit and integration tests
- @effect/vitest: Effect-aware testing utilities
- convex-test: Testing Convex functions
- Discord.js mocks: Mocking Discord API
Project Structure
Tests are co-located with source code:
apps/discord-bot/src/
├── commands/
│ └── mark-solution.ts
├── services/
│ ├── auto-thread.ts
│ └── auto-thread.test.ts # Unit tests
└── sync/
├── server.ts
└── server.test.ts
apps/bot-e2e/
└── tests/
├── smoke.test.ts # E2E tests
└── mark-solution.test.ts
Unit Testing
Testing Effect Services
From apps/discord-bot/src/sync/server.test.ts:
import { expect, it } from "@effect/vitest";
import { Database } from "@packages/database/database";
import { Effect, Layer } from "effect";
import { DiscordClientMock } from "../core/discord-client-mock";
import { TestLayer } from "../core/layers";
import { syncGuild } from "./server";
it.scoped("syncs guild data", () =>
Effect.gen(function* () {
const database = yield* Database;
const discordMock = yield* DiscordClientMock;
// Create mock guild
const guild = discordMock.utilities.createMockGuild({
description: "A test guild",
icon: "test_icon",
});
discordMock.utilities.seedGuild(guild);
// Create mock channels
const textChannel = discordMock.utilities.createMockTextChannel(guild);
discordMock.utilities.seedChannel(textChannel);
// Execute sync
yield* syncGuild(guild);
// Verify server was created
const server = yield* database.private.servers.getServerByDiscordId({
discordId: BigInt(guild.id),
});
expect(server).not.toBeNull();
expect(server?.name).toBe(guild.name);
expect(server?.description).toBe(guild.description);
}).pipe(Effect.provide(TestLayer))
);
Key Patterns
Use it.scoped for Effect tests
This provides automatic cleanup:it.scoped("test name", () =>
Effect.gen(function* () {
// Test logic
}).pipe(Effect.provide(TestLayer))
);
Provide test layers
Mock services with test layers:const TestLayer = Layer.mergeAll(
DatabaseTestLayer,
DiscordMockLayer,
ObservabilityTestLayer
);
Use generator syntax
Effect.gen makes async code readable:const result = yield* someEffect;
expect(result).toBe(expected);
Testing Convex Functions
Setting Up Tests
import { convexTest } from "convex-test";
import { expect, test } from "vitest";
import schema from "./schema";
import { upsertMessage, getMessages } from "./messages";
test("inserts and retrieves messages", async () => {
const t = convexTest(schema);
// Insert a message
await t.mutation(upsertMessage, {
message: {
id: 123n,
authorId: 456n,
serverId: 789n,
channelId: 101112n,
content: "Test message",
},
});
// Query messages
const messages = await t.query(getMessages, {
channelId: 101112n,
after: 0n,
paginationOpts: { numItems: 10, cursor: null },
});
expect(messages.page).toHaveLength(1);
expect(messages.page[0].content).toBe("Test message");
});
Testing with Relationships
test("fetches messages with attachments", async () => {
const t = convexTest(schema);
const messageId = 123n;
// Insert message
await t.mutation(upsertMessage, {
message: {
id: messageId,
authorId: 456n,
serverId: 789n,
channelId: 101112n,
content: "Message with attachment",
},
attachments: [
{
id: 999n,
messageId,
filename: "image.png",
size: 1024,
},
],
});
// Verify attachments were saved
const message = await t.query(getMessage, { messageId });
expect(message?.attachments).toHaveLength(1);
expect(message?.attachments[0].filename).toBe("image.png");
});
Integration Testing
Testing Service Interactions
import { expect, it } from "@effect/vitest";
import { Effect } from "effect";
import { handleMarkSolutionCommand } from "../commands/mark-solution";
it.scoped("marks solution and updates database", () =>
Effect.gen(function* () {
const discordMock = yield* DiscordClientMock;
const database = yield* Database;
// Setup
const guild = discordMock.utilities.createMockGuild();
const channel = discordMock.utilities.createMockTextChannel(guild);
const message = discordMock.utilities.createMockMessage(channel);
const interaction = discordMock.utilities.createMockContextMenuInteraction(
message,
"Mark Solution"
);
// Execute command
yield* handleMarkSolutionCommand(interaction, {
archiveOnComplete: true,
ephemeral: true,
});
// Verify message was marked
const dbMessage = yield* database.private.messages.getMessage({
messageId: BigInt(message.id),
});
expect(dbMessage?.isSolution).toBe(true);
// Verify Discord API was called
expect(discordMock.callHistory.editMessage).toHaveBeenCalled();
}).pipe(Effect.provide(TestLayer))
);
E2E Testing
Discord Bot E2E Tests
From apps/bot-e2e/tests/smoke.test.ts:
import { expect, it } from "@effect/vitest";
import { Effect } from "effect";
import { Selfbot, E2ELayer, CHANNELS } from "../src/core";
it.scopedLive(
"sends message and creates thread",
() =>
Effect.gen(function* () {
const selfbot = yield* Selfbot;
// Login
yield* selfbot.client.login();
// Get guild and channel
const guild = yield* selfbot.getGuild("Answer Overflow Test");
const channel = yield* selfbot.getTextChannel(guild, CHANNELS.PLAYGROUND);
// Send message
const timestamp = new Date().toISOString();
const message = yield* selfbot.sendMessage(
channel,
`E2E Test - ${timestamp}`
);
expect(message.id).toBeDefined();
expect(message.content).toContain("E2E Test");
// Create thread
const thread = yield* selfbot.createThread(
message,
`Test Thread ${timestamp}`
);
expect(thread.id).toBeDefined();
expect(thread.name).toContain("Test Thread");
// Send reply
const reply = yield* selfbot.sendMessage(thread, "Test reply");
expect(reply.id).toBeDefined();
}).pipe(Effect.provide(E2ELayer)),
{ timeout: 30000 }
);
Testing Bot Commands
it.scopedLive(
"invokes mark solution command",
() =>
Effect.gen(function* () {
const selfbot = yield* Selfbot;
yield* selfbot.client.login();
const guild = yield* selfbot.getGuild("Answer Overflow Test");
const channel = yield* selfbot.getTextChannel(guild, CHANNELS.PLAYGROUND);
// Create question and answer
const question = yield* selfbot.sendMessage(channel, "How do I test?");
const thread = yield* selfbot.createThread(question, "Test Question");
const answer = yield* selfbot.sendMessage(thread, "Like this!");
// Find mark solution command
const command = yield* selfbot.findCommand(
guild.id,
"✅ Mark Solution",
3
);
// Invoke command
yield* selfbot.invokeMessageContextMenu(
guild.id,
thread.id,
answer.id,
command
);
// Wait for processing
yield* Effect.sleep(Duration.seconds(2));
// Verify solution was marked (check Discord UI or database)
}).pipe(Effect.provide(E2ELayer)),
{ timeout: 45000 }
);
Test Configuration
Vitest Config
From vitest.config.ts:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
setupFiles: ["./test/setup.ts"],
coverage: {
provider: "v8",
reporter: ["text", "json", "html"],
exclude: [
"**/*.test.ts",
"**/*.spec.ts",
"**/node_modules/**",
"**/_generated/**",
],
},
},
});
Running Tests
# Run all tests
bun test
# Watch mode
bun test:watch
# Run specific test file
bun test mark-solution.test.ts
# Run E2E tests
cd apps/bot-e2e
bun test
# With coverage
bun test --coverage
Mocking Strategies
Mocking Discord.js
export class DiscordClientMock {
utilities = {
createMockGuild: (options?: Partial<Guild>) => ({
id: generateSnowflake(),
name: "Test Guild",
description: options?.description ?? null,
icon: options?.icon ?? null,
...options,
}),
createMockMessage: (channel: Channel, content = "Test") => ({
id: generateSnowflake(),
content,
channelId: channel.id,
guildId: channel.guildId,
author: { id: generateSnowflake(), bot: false },
}),
};
callHistory = {
sendMessage: vi.fn(),
editMessage: vi.fn(),
deleteMessage: vi.fn(),
};
}
Mocking Convex
import { convexTest } from "convex-test";
const mockConvex = convexTest(schema, {
initialData: {
messages: [
{
id: 123n,
authorId: 456n,
content: "Seeded message",
},
],
},
});
Best Practices
Follow these guidelines for maintainable tests:
- Test behavior, not implementation: Focus on what the code does, not how
- Use descriptive test names: Make failures self-explanatory
- Arrange-Act-Assert: Structure tests clearly
- Avoid test interdependence: Each test should run independently
- Mock external services: Don’t hit real APIs in tests
- Use factories: Create test data with factory functions
Example Test Factory
export function createTestMessage(overrides?: Partial<Message>) {
return {
id: BigInt(generateSnowflake()),
authorId: BigInt(generateSnowflake()),
serverId: BigInt(generateSnowflake()),
channelId: BigInt(generateSnowflake()),
content: "Test message",
...overrides,
};
}
// Usage
const message = createTestMessage({ content: "Custom content" });
Troubleshooting
Tests timing out
Increase timeout for slow tests:
it.scopedLive(
"slow test",
() => Effect.gen(function* () {
// Test logic
}),
{ timeout: 60000 } // 60 seconds
);
Effect cleanup issues
Always use it.scoped for Effect tests:
// Wrong
it("test", async () => {
const result = await Effect.runPromise(myEffect);
});
// Correct
it.scoped("test", () =>
Effect.gen(function* () {
const result = yield* myEffect;
})
);
Mock data not persisting
Ensure mocks are reset between tests:
import { beforeEach } from "vitest";
beforeEach(() => {
vi.clearAllMocks();
});
Next Steps