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.
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.
Related Articles
Building a Quiz Import/Export System
Design a robust import/export system for quizzes with JSON and CSV support, validation schemas, bulk operations, and clear error reporting.
Monitoring Quiz API Performance with Prometheus and Grafana
Instrument your quiz API with Prometheus metrics, build Grafana dashboards, and set up alerts that catch problems before users notice.
Rate Limiting Your Quiz API: A Practical Guide
Protect your quiz API from abuse with token bucket and sliding window rate limiters. Includes Redis-based implementation and graceful 429 handling.
Enjoyed this article?
Share it with your team or try our quiz platform.