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
Share:

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.

Test Your Knowledge

Think you understand Engineering? Put your skills to the test with hands-on quiz questions.

Engineering
Start Practicing

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.