Back to Blog
Engineering

Testing Your Quiz API: Unit, Integration, and E2E Strategies

A complete testing strategy for quiz APIs covering Vitest unit tests, Prisma mocking, API route integration tests, and Playwright E2E flows.

Bobby Iliev2026-04-089 min read

Tests That Actually Catch Bugs

Most quiz APIs ship with either no tests or tests that only verify the happy path. Then a user submits answers in a different order than expected, or a quiz has zero questions, or the scoring logic rounds incorrectly - and nothing catches it until production.

This guide covers a practical testing strategy with three layers: unit tests for scoring and validation logic, integration tests for API routes with a real database, and E2E tests that simulate a user completing a quiz in the browser.

Prerequisites

  • Node.js 20+
  • Vitest 2.x
  • Prisma ORM
  • Playwright for E2E tests
  • A PostgreSQL test database

Project Setup

Install the testing dependencies:

npm install -D vitest @vitest/coverage-v8 playwright @playwright/test

Configure Vitest in vitest.config.ts:

import { defineConfig } from "vitest/config";
import path from "path";

export default defineConfig({
  test: {
    globals: true,
    environment: "node",
    include: ["src/**/*.test.ts"],
    coverage: {
      provider: "v8",
      include: ["src/**/*.ts"],
      exclude: ["src/**/*.test.ts", "src/**/*.d.ts"],
    },
  },
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "src"),
    },
  },
});

Unit Testing Score Calculation

Score calculation is pure logic - no database, no HTTP. Perfect for unit tests.

Here is a scoring function at src/lib/scoring.ts:

export interface AnswerSubmission {
  questionId: string;
  selectedAnswerId: string;
}

export interface QuestionData {
  id: string;
  correctAnswerId: string;
  points: number;
}

export interface ScoreResult {
  score: number;
  maxScore: number;
  percentage: number;
  details: Array<{
    questionId: string;
    correct: boolean;
    pointsAwarded: number;
  }>;
}

export function calculateScore(
  questions: QuestionData[],
  submissions: AnswerSubmission[]
): ScoreResult {
  const submissionMap = new Map(
    submissions.map((s) => [s.questionId, s.selectedAnswerId])
  );

  let score = 0;
  const details = questions.map((question) => {
    const selected = submissionMap.get(question.id);
    const correct = selected === question.correctAnswerId;
    const pointsAwarded = correct ? question.points : 0;
    score += pointsAwarded;

    return {
      questionId: question.id,
      correct,
      pointsAwarded,
    };
  });

  const maxScore = questions.reduce((sum, q) => sum + q.points, 0);

  return {
    score,
    maxScore,
    percentage: maxScore > 0 ? Math.round((score / maxScore) * 100) : 0,
    details,
  };
}

Test it thoroughly at src/lib/scoring.test.ts:

import { describe, it, expect } from "vitest";
import { calculateScore, QuestionData, AnswerSubmission } from "./scoring";

describe("calculateScore", () => {
  const questions: QuestionData[] = [
    { id: "q1", correctAnswerId: "a1", points: 10 },
    { id: "q2", correctAnswerId: "a5", points: 10 },
    { id: "q3", correctAnswerId: "a9", points: 20 },
  ];

  it("calculates a perfect score", () => {
    const submissions: AnswerSubmission[] = [
      { questionId: "q1", selectedAnswerId: "a1" },
      { questionId: "q2", selectedAnswerId: "a5" },
      { questionId: "q3", selectedAnswerId: "a9" },
    ];

    const result = calculateScore(questions, submissions);

    expect(result.score).toBe(40);
    expect(result.maxScore).toBe(40);
    expect(result.percentage).toBe(100);
    expect(result.details.every((d) => d.correct)).toBe(true);
  });

  it("calculates a partial score", () => {
    const submissions: AnswerSubmission[] = [
      { questionId: "q1", selectedAnswerId: "a1" },
      { questionId: "q2", selectedAnswerId: "a6" }, // wrong
      { questionId: "q3", selectedAnswerId: "a9" },
    ];

    const result = calculateScore(questions, submissions);

    expect(result.score).toBe(30);
    expect(result.percentage).toBe(75);
    expect(result.details[1].correct).toBe(false);
    expect(result.details[1].pointsAwarded).toBe(0);
  });

  it("handles zero score", () => {
    const submissions: AnswerSubmission[] = [
      { questionId: "q1", selectedAnswerId: "wrong" },
      { questionId: "q2", selectedAnswerId: "wrong" },
      { questionId: "q3", selectedAnswerId: "wrong" },
    ];

    const result = calculateScore(questions, submissions);

    expect(result.score).toBe(0);
    expect(result.percentage).toBe(0);
  });

  it("handles missing submissions gracefully", () => {
    const submissions: AnswerSubmission[] = [
      { questionId: "q1", selectedAnswerId: "a1" },
      // q2 and q3 not answered
    ];

    const result = calculateScore(questions, submissions);

    expect(result.score).toBe(10);
    expect(result.details[1].correct).toBe(false);
    expect(result.details[2].correct).toBe(false);
  });

  it("handles empty questions array", () => {
    const result = calculateScore([], []);

    expect(result.score).toBe(0);
    expect(result.maxScore).toBe(0);
    expect(result.percentage).toBe(0);
    expect(result.details).toHaveLength(0);
  });

  it("ignores submissions for unknown questions", () => {
    const submissions: AnswerSubmission[] = [
      { questionId: "q1", selectedAnswerId: "a1" },
      { questionId: "q999", selectedAnswerId: "fake" }, // unknown question
    ];

    const result = calculateScore(questions, submissions);

    expect(result.details).toHaveLength(3);
    expect(result.score).toBe(10);
  });

  it("weights questions by points", () => {
    const submissions: AnswerSubmission[] = [
      { questionId: "q1", selectedAnswerId: "wrong" },
      { questionId: "q2", selectedAnswerId: "wrong" },
      { questionId: "q3", selectedAnswerId: "a9" }, // only the 20-point question
    ];

    const result = calculateScore(questions, submissions);

    expect(result.score).toBe(20);
    expect(result.percentage).toBe(50);
  });
});

Mocking Prisma for Service Tests

For service-layer tests, mock Prisma instead of hitting a real database:

// src/test/mocks/prisma.ts
import { PrismaClient } from "@prisma/client";
import { vi, beforeEach } from "vitest";
import { mockDeep, mockReset, DeepMockProxy } from "vitest-mock-extended";

export const prismaMock: DeepMockProxy<PrismaClient> = mockDeep<PrismaClient>();

vi.mock("@/lib/prisma", () => ({
  prisma: prismaMock,
}));

beforeEach(() => {
  mockReset(prismaMock);
});

Install the mocking library:

npm install -D vitest-mock-extended

Now test a service that uses Prisma at src/services/quiz.test.ts:

import { describe, it, expect, beforeEach } from "vitest";
import { prismaMock } from "@/test/mocks/prisma";
import { getQuizWithQuestions } from "./quiz";

describe("getQuizWithQuestions", () => {
  it("returns quiz with questions and answer count", async () => {
    prismaMock.quiz.findUnique.mockResolvedValue({
      id: "quiz-1",
      title: "JavaScript Basics",
      published: true,
      questions: [
        {
          id: "q1",
          text: "What is a closure?",
          answers: [
            { id: "a1", text: "A function", isCorrect: false },
            { id: "a2", text: "A function with its lexical scope", isCorrect: true },
          ],
        },
      ],
    } as any);

    const result = await getQuizWithQuestions("quiz-1");

    expect(result).not.toBeNull();
    expect(result!.title).toBe("JavaScript Basics");
    expect(result!.questions).toHaveLength(1);
    expect(prismaMock.quiz.findUnique).toHaveBeenCalledWith({
      where: { id: "quiz-1", published: true },
      include: {
        questions: { include: { answers: true } },
      },
    });
  });

  it("returns null for unpublished quiz", async () => {
    prismaMock.quiz.findUnique.mockResolvedValue(null);

    const result = await getQuizWithQuestions("nonexistent");

    expect(result).toBeNull();
  });
});

API Route Integration Tests

Test your API routes with a real HTTP server and test database:

// src/test/setup-integration.ts
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient({
  datasources: { db: { url: process.env.TEST_DATABASE_URL } },
});

export async function setupTestDb() {
  // Clean tables in correct order to respect foreign keys
  await prisma.answer.deleteMany();
  await prisma.question.deleteMany();
  await prisma.quiz.deleteMany();
  await prisma.apiKey.deleteMany();
}

export async function seedTestData() {
  const quiz = await prisma.quiz.create({
    data: {
      id: "test-quiz-1",
      title: "Test Quiz",
      published: true,
      questions: {
        create: [
          {
            id: "tq-1",
            text: "What is 2 + 2?",
            answers: {
              create: [
                { id: "ta-1", text: "3", isCorrect: false },
                { id: "ta-2", text: "4", isCorrect: true },
                { id: "ta-3", text: "5", isCorrect: false },
                { id: "ta-4", text: "22", isCorrect: false },
              ],
            },
          },
        ],
      },
    },
  });

  const apiKey = await prisma.apiKey.create({
    data: {
      key: "test-api-key-123",
      name: "Test Key",
      active: true,
    },
  });

  return { quiz, apiKey };
}

export { prisma as testPrisma };

Write the integration tests at src/routes/quizzes.integration.test.ts:

import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import { setupTestDb, seedTestData, testPrisma } from "@/test/setup-integration";
import app from "@/app";

const BASE_URL = "http://localhost:0"; // Vitest handles port assignment
let server: ReturnType<typeof app.listen>;
let port: number;
let testData: Awaited<ReturnType<typeof seedTestData>>;

beforeAll(async () => {
  server = app.listen(0);
  port = (server.address() as any).port;
});

afterAll(async () => {
  server.close();
  await testPrisma.$disconnect();
});

beforeEach(async () => {
  await setupTestDb();
  testData = await seedTestData();
});

describe("GET /api/v1/quizzes/:id", () => {
  it("returns quiz with questions", async () => {
    const res = await fetch(`http://localhost:${port}/api/v1/quizzes/test-quiz-1`, {
      headers: { Authorization: `Bearer test-api-key-123` },
    });

    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.title).toBe("Test Quiz");
    expect(body.questions).toHaveLength(1);
    expect(body.questions[0].answers).toHaveLength(4);
    // Correct answer flag should NOT be in the response
    expect(body.questions[0].answers[0]).not.toHaveProperty("isCorrect");
  });

  it("returns 404 for non-existent quiz", async () => {
    const res = await fetch(`http://localhost:${port}/api/v1/quizzes/nope`, {
      headers: { Authorization: `Bearer test-api-key-123` },
    });

    expect(res.status).toBe(404);
  });

  it("returns 401 without API key", async () => {
    const res = await fetch(`http://localhost:${port}/api/v1/quizzes/test-quiz-1`);

    expect(res.status).toBe(401);
  });
});

describe("POST /api/v1/quizzes/:id/submit", () => {
  it("scores correct answers", async () => {
    const res = await fetch(`http://localhost:${port}/api/v1/quizzes/test-quiz-1/submit`, {
      method: "POST",
      headers: {
        Authorization: `Bearer test-api-key-123`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        answers: { "tq-1": "ta-2" },
      }),
    });

    expect(res.status).toBe(200);

    const body = await res.json();
    expect(body.score).toBe(1);
    expect(body.total).toBe(1);
    expect(body.percentage).toBe(100);
  });

  it("scores incorrect answers", async () => {
    const res = await fetch(`http://localhost:${port}/api/v1/quizzes/test-quiz-1/submit`, {
      method: "POST",
      headers: {
        Authorization: `Bearer test-api-key-123`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        answers: { "tq-1": "ta-1" },
      }),
    });

    const body = await res.json();
    expect(body.score).toBe(0);
    expect(body.percentage).toBe(0);
  });

  it("rejects empty submission", async () => {
    const res = await fetch(`http://localhost:${port}/api/v1/quizzes/test-quiz-1/submit`, {
      method: "POST",
      headers: {
        Authorization: `Bearer test-api-key-123`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ answers: {} }),
    });

    expect(res.status).toBe(400);
  });
});

Playwright E2E Tests

Test the full user flow in a browser at e2e/quiz-flow.spec.ts:

import { test, expect } from "@playwright/test";

test.describe("Quiz Flow", () => {
  test("complete a quiz and see results", async ({ page }) => {
    await page.goto("/quizzes");

    // Find and click a quiz
    await page.getByRole("link", { name: /JavaScript Basics/i }).click();

    // Start the quiz
    await page.getByRole("button", { name: "Start Quiz" }).click();

    // Answer the first question
    await expect(page.getByText("Question 1 of")).toBeVisible();
    await page.getByRole("radio").first().click();
    await page.getByRole("button", { name: "Next" }).click();

    // Answer remaining questions (click first option for each)
    while (await page.getByRole("button", { name: "Next" }).isVisible()) {
      await page.getByRole("radio").first().click();
      await page.getByRole("button", { name: "Next" }).click();
    }

    // Submit the last question
    await page.getByRole("radio").first().click();
    await page.getByRole("button", { name: "Submit" }).click();

    // Verify results page
    await expect(page.getByText("Quiz Complete")).toBeVisible();
    await expect(page.getByText(/\d+\/\d+/)).toBeVisible();
    await expect(page.getByText(/%/)).toBeVisible();
  });

  test("navigate back and change answer", async ({ page }) => {
    await page.goto("/quizzes/test-quiz/start");

    // Answer first question
    await page.getByRole("radio").nth(0).click();
    await page.getByRole("button", { name: "Next" }).click();

    // Go back
    await page.getByRole("button", { name: "Previous" }).click();

    // First option should still be selected
    await expect(page.getByRole("radio").nth(0)).toBeChecked();

    // Change answer
    await page.getByRole("radio").nth(1).click();
    await expect(page.getByRole("radio").nth(1)).toBeChecked();
    await expect(page.getByRole("radio").nth(0)).not.toBeChecked();
  });

  test("keyboard navigation works", async ({ page }) => {
    await page.goto("/quizzes/test-quiz/start");

    // Tab to first answer option
    await page.keyboard.press("Tab");
    await page.keyboard.press("Tab");

    // Select with space
    await page.keyboard.press("Space");

    // Tab to Next button and press Enter
    await page.keyboard.press("Tab");
    await page.keyboard.press("Enter");

    // Should be on question 2
    await expect(page.getByText("Question 2 of")).toBeVisible();
  });
});

Configure Playwright in playwright.config.ts:

import { defineConfig } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  timeout: 30000,
  use: {
    baseURL: "http://localhost:3000",
    trace: "on-first-retry",
  },
  webServer: {
    command: "npm run dev",
    port: 3000,
    reuseExistingServer: !process.env.CI,
  },
});

Running the Tests

Add scripts to package.json:

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage",
    "test:integration": "DATABASE_URL=$TEST_DATABASE_URL vitest run --config vitest.integration.config.ts",
    "test:e2e": "playwright test",
    "test:all": "npm run test && npm run test:integration && npm run test:e2e"
  }
}

Summary

A solid testing strategy uses each layer for what it does best:

  • Unit tests for pure logic like scoring, validation, and data transformation. Fast, no dependencies.
  • Integration tests for API routes with a real database. Catches query bugs, auth issues, and response shape problems.
  • E2E tests for critical user flows. Catches UI bugs, navigation issues, and accessibility problems.

Start with unit tests for your scoring logic - that is where the highest-value bugs hide. Add integration tests for your API routes. Then cover the main quiz flow with one or two E2E tests. You do not need 100% coverage to catch the bugs that matter.

Stay Updated

Get the latest tutorials and API tips delivered to your inbox.

No spam, unsubscribe anytime.

Enjoyed this article?

Share it with your team or try our quiz platform.