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:
1import { defineConfig } from "vitest/config"; 2import path from "path"; 3 4export default defineConfig({ 5 test: { 6 globals: true, 7 environment: "node", 8 include: ["src/**/*.test.ts"], 9 coverage: { 10 provider: "v8", 11 include: ["src/**/*.ts"], 12 exclude: ["src/**/*.test.ts", "src/**/*.d.ts"], 13 }, 14 }, 15 resolve: { 16 alias: { 17 "@": path.resolve(__dirname, "src"), 18 }, 19 }, 20});
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:
1export interface AnswerSubmission { 2 questionId: string; 3 selectedAnswerId: string; 4} 5 6export interface QuestionData { 7 id: string; 8 correctAnswerId: string; 9 points: number; 10} 11 12export interface ScoreResult { 13 score: number; 14 maxScore: number; 15 percentage: number; 16 details: Array<{ 17 questionId: string; 18 correct: boolean; 19 pointsAwarded: number; 20 }>; 21} 22 23export function calculateScore( 24 questions: QuestionData[], 25 submissions: AnswerSubmission[] 26): ScoreResult { 27 const submissionMap = new Map( 28 submissions.map((s) => [s.questionId, s.selectedAnswerId]) 29 ); 30 31 let score = 0; 32 const details = questions.map((question) => { 33 const selected = submissionMap.get(question.id); 34 const correct = selected === question.correctAnswerId; 35 const pointsAwarded = correct ? question.points : 0; 36 score += pointsAwarded; 37 38 return { 39 questionId: question.id, 40 correct, 41 pointsAwarded, 42 }; 43 }); 44 45 const maxScore = questions.reduce((sum, q) => sum + q.points, 0); 46 47 return { 48 score, 49 maxScore, 50 percentage: maxScore > 0 ? Math.round((score / maxScore) * 100) : 0, 51 details, 52 }; 53}
Test it thoroughly at src/lib/scoring.test.ts:
1import { describe, it, expect } from "vitest"; 2import { calculateScore, QuestionData, AnswerSubmission } from "./scoring"; 3 4describe("calculateScore", () => { 5 const questions: QuestionData[] = [ 6 { id: "q1", correctAnswerId: "a1", points: 10 }, 7 { id: "q2", correctAnswerId: "a5", points: 10 }, 8 { id: "q3", correctAnswerId: "a9", points: 20 }, 9 ]; 10 11 it("calculates a perfect score", () => { 12 const submissions: AnswerSubmission[] = [ 13 { questionId: "q1", selectedAnswerId: "a1" }, 14 { questionId: "q2", selectedAnswerId: "a5" }, 15 { questionId: "q3", selectedAnswerId: "a9" }, 16 ]; 17 18 const result = calculateScore(questions, submissions); 19 20 expect(result.score).toBe(40); 21 expect(result.maxScore).toBe(40); 22 expect(result.percentage).toBe(100); 23 expect(result.details.every((d) => d.correct)).toBe(true); 24 }); 25 26 it("calculates a partial score", () => { 27 const submissions: AnswerSubmission[] = [ 28 { questionId: "q1", selectedAnswerId: "a1" }, 29 { questionId: "q2", selectedAnswerId: "a6" }, // wrong 30 { questionId: "q3", selectedAnswerId: "a9" }, 31 ]; 32 33 const result = calculateScore(questions, submissions); 34 35 expect(result.score).toBe(30); 36 expect(result.percentage).toBe(75); 37 expect(result.details[1].correct).toBe(false); 38 expect(result.details[1].pointsAwarded).toBe(0); 39 }); 40 41 it("handles zero score", () => { 42 const submissions: AnswerSubmission[] = [ 43 { questionId: "q1", selectedAnswerId: "wrong" }, 44 { questionId: "q2", selectedAnswerId: "wrong" }, 45 { questionId: "q3", selectedAnswerId: "wrong" }, 46 ]; 47 48 const result = calculateScore(questions, submissions); 49 50 expect(result.score).toBe(0); 51 expect(result.percentage).toBe(0); 52 }); 53 54 it("handles missing submissions gracefully", () => { 55 const submissions: AnswerSubmission[] = [ 56 { questionId: "q1", selectedAnswerId: "a1" }, 57 // q2 and q3 not answered 58 ]; 59 60 const result = calculateScore(questions, submissions); 61 62 expect(result.score).toBe(10); 63 expect(result.details[1].correct).toBe(false); 64 expect(result.details[2].correct).toBe(false); 65 }); 66 67 it("handles empty questions array", () => { 68 const result = calculateScore([], []); 69 70 expect(result.score).toBe(0); 71 expect(result.maxScore).toBe(0); 72 expect(result.percentage).toBe(0); 73 expect(result.details).toHaveLength(0); 74 }); 75 76 it("ignores submissions for unknown questions", () => { 77 const submissions: AnswerSubmission[] = [ 78 { questionId: "q1", selectedAnswerId: "a1" }, 79 { questionId: "q999", selectedAnswerId: "fake" }, // unknown question 80 ]; 81 82 const result = calculateScore(questions, submissions); 83 84 expect(result.details).toHaveLength(3); 85 expect(result.score).toBe(10); 86 }); 87 88 it("weights questions by points", () => { 89 const submissions: AnswerSubmission[] = [ 90 { questionId: "q1", selectedAnswerId: "wrong" }, 91 { questionId: "q2", selectedAnswerId: "wrong" }, 92 { questionId: "q3", selectedAnswerId: "a9" }, // only the 20-point question 93 ]; 94 95 const result = calculateScore(questions, submissions); 96 97 expect(result.score).toBe(20); 98 expect(result.percentage).toBe(50); 99 }); 100});
Mocking Prisma for Service Tests
For service-layer tests, mock Prisma instead of hitting a real database:
1// src/test/mocks/prisma.ts 2import { PrismaClient } from "@prisma/client"; 3import { vi, beforeEach } from "vitest"; 4import { mockDeep, mockReset, DeepMockProxy } from "vitest-mock-extended"; 5 6export const prismaMock: DeepMockProxy<PrismaClient> = mockDeep<PrismaClient>(); 7 8vi.mock("@/lib/prisma", () => ({ 9 prisma: prismaMock, 10})); 11 12beforeEach(() => { 13 mockReset(prismaMock); 14});
Install the mocking library:
npm install -D vitest-mock-extended
Now test a service that uses Prisma at src/services/quiz.test.ts:
1import { describe, it, expect, beforeEach } from "vitest"; 2import { prismaMock } from "@/test/mocks/prisma"; 3import { getQuizWithQuestions } from "./quiz"; 4 5describe("getQuizWithQuestions", () => { 6 it("returns quiz with questions and answer count", async () => { 7 prismaMock.quiz.findUnique.mockResolvedValue({ 8 id: "quiz-1", 9 title: "JavaScript Basics", 10 published: true, 11 questions: [ 12 { 13 id: "q1", 14 text: "What is a closure?", 15 answers: [ 16 { id: "a1", text: "A function", isCorrect: false }, 17 { id: "a2", text: "A function with its lexical scope", isCorrect: true }, 18 ], 19 }, 20 ], 21 } as any); 22 23 const result = await getQuizWithQuestions("quiz-1"); 24 25 expect(result).not.toBeNull(); 26 expect(result!.title).toBe("JavaScript Basics"); 27 expect(result!.questions).toHaveLength(1); 28 expect(prismaMock.quiz.findUnique).toHaveBeenCalledWith({ 29 where: { id: "quiz-1", published: true }, 30 include: { 31 questions: { include: { answers: true } }, 32 }, 33 }); 34 }); 35 36 it("returns null for unpublished quiz", async () => { 37 prismaMock.quiz.findUnique.mockResolvedValue(null); 38 39 const result = await getQuizWithQuestions("nonexistent"); 40 41 expect(result).toBeNull(); 42 }); 43});
API Route Integration Tests
Test your API routes with a real HTTP server and test database:
1// src/test/setup-integration.ts 2import { PrismaClient } from "@prisma/client"; 3 4const prisma = new PrismaClient({ 5 datasources: { db: { url: process.env.TEST_DATABASE_URL } }, 6}); 7 8export async function setupTestDb() { 9 // Clean tables in correct order to respect foreign keys 10 await prisma.answer.deleteMany(); 11 await prisma.question.deleteMany(); 12 await prisma.quiz.deleteMany(); 13 await prisma.apiKey.deleteMany(); 14} 15 16export async function seedTestData() { 17 const quiz = await prisma.quiz.create({ 18 data: { 19 id: "test-quiz-1", 20 title: "Test Quiz", 21 published: true, 22 questions: { 23 create: [ 24 { 25 id: "tq-1", 26 text: "What is 2 + 2?", 27 answers: { 28 create: [ 29 { id: "ta-1", text: "3", isCorrect: false }, 30 { id: "ta-2", text: "4", isCorrect: true }, 31 { id: "ta-3", text: "5", isCorrect: false }, 32 { id: "ta-4", text: "22", isCorrect: false }, 33 ], 34 }, 35 }, 36 ], 37 }, 38 }, 39 }); 40 41 const apiKey = await prisma.apiKey.create({ 42 data: { 43 key: "test-api-key-123", 44 name: "Test Key", 45 active: true, 46 }, 47 }); 48 49 return { quiz, apiKey }; 50} 51 52export { prisma as testPrisma };
Write the integration tests at src/routes/quizzes.integration.test.ts:
1import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"; 2import { setupTestDb, seedTestData, testPrisma } from "@/test/setup-integration"; 3import app from "@/app"; 4 5const BASE_URL = "http://localhost:0"; // Vitest handles port assignment 6let server: ReturnType<typeof app.listen>; 7let port: number; 8let testData: Awaited<ReturnType<typeof seedTestData>>; 9 10beforeAll(async () => { 11 server = app.listen(0); 12 port = (server.address() as any).port; 13}); 14 15afterAll(async () => { 16 server.close(); 17 await testPrisma.$disconnect(); 18}); 19 20beforeEach(async () => { 21 await setupTestDb(); 22 testData = await seedTestData(); 23}); 24 25describe("GET /api/v1/quizzes/:id", () => { 26 it("returns quiz with questions", async () => { 27 const res = await fetch(`http://localhost:${port}/api/v1/quizzes/test-quiz-1`, { 28 headers: { Authorization: `Bearer test-api-key-123` }, 29 }); 30 31 expect(res.status).toBe(200); 32 33 const body = await res.json(); 34 expect(body.title).toBe("Test Quiz"); 35 expect(body.questions).toHaveLength(1); 36 expect(body.questions[0].answers).toHaveLength(4); 37 // Correct answer flag should NOT be in the response 38 expect(body.questions[0].answers[0]).not.toHaveProperty("isCorrect"); 39 }); 40 41 it("returns 404 for non-existent quiz", async () => { 42 const res = await fetch(`http://localhost:${port}/api/v1/quizzes/nope`, { 43 headers: { Authorization: `Bearer test-api-key-123` }, 44 }); 45 46 expect(res.status).toBe(404); 47 }); 48 49 it("returns 401 without API key", async () => { 50 const res = await fetch(`http://localhost:${port}/api/v1/quizzes/test-quiz-1`); 51 52 expect(res.status).toBe(401); 53 }); 54}); 55 56describe("POST /api/v1/quizzes/:id/submit", () => { 57 it("scores correct answers", async () => { 58 const res = await fetch(`http://localhost:${port}/api/v1/quizzes/test-quiz-1/submit`, { 59 method: "POST", 60 headers: { 61 Authorization: `Bearer test-api-key-123`, 62 "Content-Type": "application/json", 63 }, 64 body: JSON.stringify({ 65 answers: { "tq-1": "ta-2" }, 66 }), 67 }); 68 69 expect(res.status).toBe(200); 70 71 const body = await res.json(); 72 expect(body.score).toBe(1); 73 expect(body.total).toBe(1); 74 expect(body.percentage).toBe(100); 75 }); 76 77 it("scores incorrect answers", async () => { 78 const res = await fetch(`http://localhost:${port}/api/v1/quizzes/test-quiz-1/submit`, { 79 method: "POST", 80 headers: { 81 Authorization: `Bearer test-api-key-123`, 82 "Content-Type": "application/json", 83 }, 84 body: JSON.stringify({ 85 answers: { "tq-1": "ta-1" }, 86 }), 87 }); 88 89 const body = await res.json(); 90 expect(body.score).toBe(0); 91 expect(body.percentage).toBe(0); 92 }); 93 94 it("rejects empty submission", async () => { 95 const res = await fetch(`http://localhost:${port}/api/v1/quizzes/test-quiz-1/submit`, { 96 method: "POST", 97 headers: { 98 Authorization: `Bearer test-api-key-123`, 99 "Content-Type": "application/json", 100 }, 101 body: JSON.stringify({ answers: {} }), 102 }); 103 104 expect(res.status).toBe(400); 105 }); 106});
Playwright E2E Tests
Test the full user flow in a browser at e2e/quiz-flow.spec.ts:
1import { test, expect } from "@playwright/test"; 2 3test.describe("Quiz Flow", () => { 4 test("complete a quiz and see results", async ({ page }) => { 5 await page.goto("/quizzes"); 6 7 // Find and click a quiz 8 await page.getByRole("link", { name: /JavaScript Basics/i }).click(); 9 10 // Start the quiz 11 await page.getByRole("button", { name: "Start Quiz" }).click(); 12 13 // Answer the first question 14 await expect(page.getByText("Question 1 of")).toBeVisible(); 15 await page.getByRole("radio").first().click(); 16 await page.getByRole("button", { name: "Next" }).click(); 17 18 // Answer remaining questions (click first option for each) 19 while (await page.getByRole("button", { name: "Next" }).isVisible()) { 20 await page.getByRole("radio").first().click(); 21 await page.getByRole("button", { name: "Next" }).click(); 22 } 23 24 // Submit the last question 25 await page.getByRole("radio").first().click(); 26 await page.getByRole("button", { name: "Submit" }).click(); 27 28 // Verify results page 29 await expect(page.getByText("Quiz Complete")).toBeVisible(); 30 await expect(page.getByText(/\d+\/\d+/)).toBeVisible(); 31 await expect(page.getByText(/%/)).toBeVisible(); 32 }); 33 34 test("navigate back and change answer", async ({ page }) => { 35 await page.goto("/quizzes/test-quiz/start"); 36 37 // Answer first question 38 await page.getByRole("radio").nth(0).click(); 39 await page.getByRole("button", { name: "Next" }).click(); 40 41 // Go back 42 await page.getByRole("button", { name: "Previous" }).click(); 43 44 // First option should still be selected 45 await expect(page.getByRole("radio").nth(0)).toBeChecked(); 46 47 // Change answer 48 await page.getByRole("radio").nth(1).click(); 49 await expect(page.getByRole("radio").nth(1)).toBeChecked(); 50 await expect(page.getByRole("radio").nth(0)).not.toBeChecked(); 51 }); 52 53 test("keyboard navigation works", async ({ page }) => { 54 await page.goto("/quizzes/test-quiz/start"); 55 56 // Tab to first answer option 57 await page.keyboard.press("Tab"); 58 await page.keyboard.press("Tab"); 59 60 // Select with space 61 await page.keyboard.press("Space"); 62 63 // Tab to Next button and press Enter 64 await page.keyboard.press("Tab"); 65 await page.keyboard.press("Enter"); 66 67 // Should be on question 2 68 await expect(page.getByText("Question 2 of")).toBeVisible(); 69 }); 70});
Configure Playwright in playwright.config.ts:
1import { defineConfig } from "@playwright/test"; 2 3export default defineConfig({ 4 testDir: "./e2e", 5 timeout: 30000, 6 use: { 7 baseURL: "http://localhost:3000", 8 trace: "on-first-retry", 9 }, 10 webServer: { 11 command: "npm run dev", 12 port: 3000, 13 reuseExistingServer: !process.env.CI, 14 }, 15});
Running the Tests
Add scripts to package.json:
1{ 2 "scripts": { 3 "test": "vitest run", 4 "test:watch": "vitest", 5 "test:coverage": "vitest run --coverage", 6 "test:integration": "DATABASE_URL=$TEST_DATABASE_URL vitest run --config vitest.integration.config.ts", 7 "test:e2e": "playwright test", 8 "test:all": "npm run test && npm run test:integration && npm run test:e2e" 9 } 10}
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.
Think you understand Engineering? Put your skills to the test with hands-on quiz questions.
Enjoyed this article?
Share it with your team or try our quiz platform.
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.