Skip to main content
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

1

Use it.scoped for Effect tests

This provides automatic cleanup:
it.scoped("test name", () =>
  Effect.gen(function* () {
    // Test logic
  }).pipe(Effect.provide(TestLayer))
);
2

Provide test layers

Mock services with test layers:
const TestLayer = Layer.mergeAll(
  DatabaseTestLayer,
  DiscordMockLayer,
  ObservabilityTestLayer
);
3

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:
  1. Test behavior, not implementation: Focus on what the code does, not how
  2. Use descriptive test names: Make failures self-explanatory
  3. Arrange-Act-Assert: Structure tests clearly
  4. Avoid test interdependence: Each test should run independently
  5. Mock external services: Don’t hit real APIs in tests
  6. 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