Test-Driven Development: Writing Better Code

Test-Driven Development: Writing Better Code

16 min read
15 views

Support Free C++ Education

Help us create more high-quality C++ learning content. Your support enables us to build more interactive projects, write comprehensive tutorials, and keep all content free for everyone.

Become a Patron

Why Test-Driven Development?

When I started building HelloC++, I'll admit: I didn't write tests first. I'd write code, manually test it in the browser, fix bugs, and deploy. It worked, but as the platform and codebase grew, this approach became unsustainable.

Features that seemed simple broke in unexpected ways. Bug fixes introduced new bugs. Refactoring was terrifying because I couldn't be sure what would break.

Then I committed to Test-Driven Development (TDD). The transformation was remarkable.

This article shares how we practice TDD at HelloC++ using Jest, a delightful JavaScript testing framework, and how it's made us more productive and confident developers.

What is Test-Driven Development?

TDD is a development methodology where you write tests before writing code. The cycle is simple:

  1. Red: Write a failing test
  2. Green: Write minimal code to make it pass
  3. Refactor: Clean up the code while keeping tests green

This might seem backward at first. Why write a test for code that doesn't exist? But this is precisely what makes TDD powerful.

Why Jest?

We chose Jest for several reasons:

Zero Configuration

Jest works out of the box for most JavaScript projects with minimal setup. No complex configuration files or plugin chains.

Built-in Mocking and Assertions

Everything you need is included:

  • Powerful mocking capabilities
  • Snapshot testing
  • Code coverage reports
  • Fast parallel test execution

Clean, Expressive Syntax

Jest tests are readable and maintainable:

describe("LessonCompletion", () => {
    it("marks lesson complete when user passes all exercises", async () => {
        const user = await createUser();
        const lesson = await createLesson();
        const exercise = await createExercise({ lessonId: lesson.id });

        await createSubmission({
            userId: user.id,
            exerciseId: exercise.id,
            status: "passed"
        });

        const service = new LessonCompletionService();
        await service.checkAndUpdateLessonCompletion(user, lesson);

        const progress = await UserProgress.findOne({
            where: { userId: user.id }
        });
        expect(progress.status).toBe("completed");
    });
});

Better Expectations API

Jest's expectations are intuitive:

expect(collection).toHaveLength(3);
expect(array).toContain("item");
expect(value).toBeGreaterThan(10);
expect(object).toMatchObject({ name: "test" });
expect(async () => riskyFunction()).rejects.toThrow();

Shared Setup with beforeEach

Jest makes test setup clean and reusable:

describe("Lesson Access", () => {
    let user, course, lesson;

    beforeEach(async () => {
        user = await createUser();
        course = await createCourse();
        lesson = await createLesson({ courseId: course.id });
    });

    it("allows enrolled user to access lesson", async () => {
        await user.enrollInCourse(course.id);

        const response = await request(app)
            .get(`/lesson/${lesson.slug}`)
            .set("Authorization", `Bearer ${user.token}`);

        expect(response.status).toBe(200);
    });

    it("prevents non-enrolled user from accessing lesson", async () => {
        const response = await request(app)
            .get(`/lesson/${lesson.slug}`)
            .set("Authorization", `Bearer ${user.token}`);

        expect(response.status).toBe(403);
    });
});

Our TDD Workflow

Let's walk through building a real feature using TDD: implementing quiz completion tracking.

Step 1: Write a Failing Test (Red)

Before writing any implementation code, we write a test describing what we want:

// tests/quiz/quizCompletion.test.js
const request = require("supertest");
const { app } = require("../../app");
const { createUser, createQuiz } = require("../helpers/factories");

describe("Quiz Completion", () => {
    it("marks quiz as completed when user achieves passing score", async () => {
        const user = await createUser();
        const quiz = await createQuiz({ passingScore: 70 });

        // Submit quiz with passing score
        const response = await request(app)
            .post(`/quiz/${quiz.id}/submit`)
            .set("Authorization", `Bearer ${user.token}`)
            .send({
                answers: {
                    1: "A",
                    2: "B",
                    3: "C"
                }
            });

        // Verify quiz marked as completed
        expect(response.status).toBe(200);
        expect(response.body.status).toBe("completed");
        expect(response.body.score).toBeGreaterThanOrEqual(70);

        const attempt = await QuizAttempt.findOne({
            where: {
                userId: user.id,
                quizId: quiz.id
            }
        });

        expect(attempt.status).toBe("completed");
    });
});

Run the test:

npm test -- quizCompletion.test.js

It fails because the route doesn't exist, the model doesn't exist, nothing exists. That's expected - we're writing tests first!

Step 2: Make It Pass (Green)

Now write the minimal code to make the test pass:

Create the database model:

// models/QuizAttempt.js
const { DataTypes } = require("sequelize");
const { sequelize } = require("../database");

const QuizAttempt = sequelize.define(
    "QuizAttempt",
    {
        id: {
            type: DataTypes.INTEGER,
            primaryKey: true,
            autoIncrement: true
        },
        userId: {
            type: DataTypes.INTEGER,
            allowNull: false,
            references: {
                model: "Users",
                key: "id"
            }
        },
        quizId: {
            type: DataTypes.INTEGER,
            allowNull: false,
            references: {
                model: "Quizzes",
                key: "id"
            }
        },
        answers: {
            type: DataTypes.JSON,
            allowNull: false
        },
        score: {
            type: DataTypes.INTEGER,
            allowNull: false
        },
        status: {
            type: DataTypes.ENUM("in_progress", "completed"),
            allowNull: false
        }
    },
    {
        tableName: "quiz_attempts",
        timestamps: true
    }
);

module.exports = QuizAttempt;

Create the controller:

// controllers/quizSubmissionController.js
const QuizAttempt = require("../models/QuizAttempt");
const Quiz = require("../models/Quiz");

async function submitQuiz(req, res) {
    try {
        const { quizId } = req.params;
        const { answers } = req.body;
        const userId = req.user.id;

        const quiz = await Quiz.findByPk(quizId, {
            include: ["questions"]
        });

        if (!quiz) {
            return res.status(404).json({ error: "Quiz not found" });
        }

        const score = calculateScore(quiz, answers);
        const status = score >= quiz.passingScore ? "completed" : "in_progress";

        const attempt = await QuizAttempt.create({
            userId,
            quizId: quiz.id,
            answers,
            score,
            status
        });

        return res.json(attempt);
    } catch (error) {
        return res.status(500).json({ error: error.message });
    }
}

function calculateScore(quiz, answers) {
    let correctCount = 0;
    const totalQuestions = quiz.questions.length;

    quiz.questions.forEach((question) => {
        const userAnswer = answers[question.id];
        if (userAnswer === question.correctAnswer) {
            correctCount++;
        }
    });

    return totalQuestions > 0
        ? Math.floor((correctCount / totalQuestions) * 100)
        : 0;
}

module.exports = { submitQuiz };

Add the route:

// routes/quiz.js
const express = require("express");
const router = express.Router();
const { submitQuiz } = require("../controllers/quizSubmissionController");
const { authenticate } = require("../middleware/auth");

router.post("/:quizId/submit", authenticate, submitQuiz);

module.exports = router;

Run the test again:

npm test -- quizCompletion.test.js

Green! The test passes.

Step 3: Refactor

Now that tests are passing, we can refactor safely. Let's move the scoring logic to a dedicated service:

// services/QuizScoringService.js
class QuizScoringService {
    calculateScore(quiz, answers) {
        let correctCount = 0;
        const totalQuestions = quiz.questions.length;

        quiz.questions.forEach((question) => {
            const userAnswer = answers[question.id];
            if (userAnswer === question.correctAnswer) {
                correctCount++;
            }
        });

        return totalQuestions > 0
            ? Math.floor((correctCount / totalQuestions) * 100)
            : 0;
    }

    isPassing(score, passingScore) {
        return score >= passingScore;
    }
}

module.exports = QuizScoringService;

Update the controller:

// controllers/quizSubmissionController.js
const QuizAttempt = require("../models/QuizAttempt");
const Quiz = require("../models/Quiz");
const QuizScoringService = require("../services/QuizScoringService");

const scoringService = new QuizScoringService();

async function submitQuiz(req, res) {
    try {
        const { quizId } = req.params;
        const { answers } = req.body;
        const userId = req.user.id;

        const quiz = await Quiz.findByPk(quizId, {
            include: ["questions"]
        });

        if (!quiz) {
            return res.status(404).json({ error: "Quiz not found" });
        }

        const score = scoringService.calculateScore(quiz, answers);
        const status = scoringService.isPassing(score, quiz.passingScore)
            ? "completed"
            : "in_progress";

        const attempt = await QuizAttempt.create({
            userId,
            quizId: quiz.id,
            answers,
            score,
            status
        });

        return res.json(attempt);
    } catch (error) {
        return res.status(500).json({ error: error.message });
    }
}

module.exports = { submitQuiz };

Run tests again to ensure refactoring didn't break anything:

npm test -- quizCompletion.test.js

Still green! We've successfully refactored with confidence.

Step 4: Add More Test Cases

Now that the basic functionality works, we add tests for edge cases:

describe("Quiz Completion", () => {
    it("marks quiz as completed when user achieves passing score", async () => {
        // ... previous test
    });

    it("marks quiz as in_progress when user fails to achieve passing score", async () => {
        const user = await createUser();
        const quiz = await createQuiz({ passingScore: 70 });

        const response = await request(app)
            .post(`/quiz/${quiz.id}/submit`)
            .set("Authorization", `Bearer ${user.token}`)
            .send({ answers: { 1: "WRONG" } });

        expect(response.body.status).toBe("in_progress");
        expect(response.body.score).toBeLessThan(70);
    });

    it("handles quiz with no questions", async () => {
        const user = await createUser();
        const quiz = await createQuiz({
            passingScore: 70,
            questions: []
        });

        const response = await request(app)
            .post(`/quiz/${quiz.id}/submit`)
            .set("Authorization", `Bearer ${user.token}`)
            .send({ answers: {} });

        expect(response.body.score).toBe(0);
        expect(response.body.status).toBe("in_progress");
    });

    it("allows user to retake failed quiz", async () => {
        const user = await createUser();
        const quiz = await createQuiz({ passingScore: 70 });

        // First attempt - fail
        await request(app)
            .post(`/quiz/${quiz.id}/submit`)
            .set("Authorization", `Bearer ${user.token}`)
            .send({ answers: { 1: "WRONG" } });

        // Second attempt - pass
        await request(app)
            .post(`/quiz/${quiz.id}/submit`)
            .set("Authorization", `Bearer ${user.token}`)
            .send({ answers: { 1: "CORRECT", 2: "CORRECT" } });

        const attempts = await QuizAttempt.findAll({
            where: {
                userId: user.id,
                quizId: quiz.id
            }
        });

        expect(attempts).toHaveLength(2);
        expect(attempts[1].status).toBe("completed");
    });
});

This is the power of TDD: we think through edge cases upfront and codify them as tests.

Critical TDD Principles We Follow

1. Always Use Dependency Injection

When testing classes with dependencies, inject them for easier testing and mocking:

// Good - Uses dependency injection
class UserStatisticsService {
    constructor(progressRepository) {
        this.progressRepository = progressRepository;
    }

    async getUserStats(user) {
        const progress = await this.progressRepository.getForUser(user.id);
        return {
            lessonsCompleted: progress.filter((p) => p.status === "completed")
                .length,
            totalLessons: progress.length
        };
    }
}

// Test
it("calculates user statistics", async () => {
    const mockRepository = {
        getForUser: jest
            .fn()
            .mockResolvedValue([
                { status: "completed" },
                { status: "in_progress" }
            ])
    };

    const service = new UserStatisticsService(mockRepository);
    const stats = await service.getUserStats({ id: 1 });

    expect(stats.lessonsCompleted).toBe(1);
    expect(mockRepository.getForUser).toHaveBeenCalledWith(1);
});
// Bad - Hard to test
class UserStatisticsService {
    async getUserStats(user) {
        const repository = new UserProgressRepository(); // Hard-coded dependency
        const progress = await repository.getForUser(user.id);
        // ... rest of logic
    }
}

Why use dependency injection:

  • Enables easy mocking with jest.fn()
  • Makes dependencies explicit
  • Supports different implementations
  • Makes tests maintainable when dependencies change

2. Test Behavior, Not Implementation

Focus on what the code does, not how it does it:

// Good - tests observable behavior
it("sends welcome email after registration", async () => {
    const emailService = {
        send: jest.fn().mockResolvedValue(true)
    };

    const authService = new AuthService(emailService);
    await authService.register({
        name: "John Doe",
        email: "john@example.com",
        password: "password123"
    });

    expect(emailService.send).toHaveBeenCalledWith({
        to: "john@example.com",
        template: "welcome",
        data: expect.objectContaining({ name: "John Doe" })
    });
});

// Bad - tests implementation details
it("creates user record in database", async () => {
    const mockCreate = jest.spyOn(User, "create");

    await authService.register(userData);

    // This tests HOW registration works, not WHAT it does
    expect(mockCreate).toHaveBeenCalled();
});

3. Use Factory Functions, Not Manual Object Creation

Factory functions make test data creation consistent:

// helpers/factories.js
const { User, Lesson, Exercise } = require("../models");

async function createUser(overrides = {}) {
    return await User.create({
        name: "Test User",
        email: `test${Date.now()}@example.com`,
        password: "password123",
        ...overrides
    });
}

async function createLesson(overrides = {}) {
    return await Lesson.create({
        title: "Test Lesson",
        slug: `test-lesson-${Date.now()}`,
        content: "Test content",
        ...overrides
    });
}

async function createExercise(overrides = {}) {
    const lesson = overrides.lessonId
        ? await Lesson.findByPk(overrides.lessonId)
        : await createLesson();

    return await Exercise.create({
        lessonId: lesson.id,
        title: "Test Exercise",
        instructions: "Test instructions",
        ...overrides
    });
}

module.exports = { createUser, createLesson, createExercise };
// Good - uses factory
it("calculates lesson completion percentage", async () => {
    const user = await createUser();
    const lesson = await createLesson();
    const exercises = await Promise.all([
        createExercise({ lessonId: lesson.id }),
        createExercise({ lessonId: lesson.id }),
        createExercise({ lessonId: lesson.id })
    ]);

    // Test implementation
});

// Bad - manual model creation
it("calculates lesson completion percentage", async () => {
    const user = await User.create({
        name: "Test User",
        email: "test@example.com",
        password: "password"
    });

    // Tedious and error-prone
});

4. Test One Thing Per Test

Each test should verify one specific behavior:

// Good - focused test
it("marks lesson as completed when all exercises pass", async () => {
    const user = await createUser();
    const lesson = await createLesson();
    const exercise = await createExercise({ lessonId: lesson.id });

    await createSubmission({
        userId: user.id,
        exerciseId: exercise.id,
        status: "passed"
    });

    const service = new LessonCompletionService();
    await service.checkAndUpdateLessonCompletion(user, lesson);

    const progress = await UserProgress.findOne({
        where: { userId: user.id }
    });
    expect(progress.status).toBe("completed");
});

// Bad - tests multiple things
it("handles lesson completion workflow", async () => {
    // Creates user
    // Enrolls in course
    // Completes exercises
    // Marks lesson complete
    // Unlocks achievement
    // Sends notification
    // Too much in one test!
});

5. Use Descriptive Test Names

Test names should describe what's being tested:

// Good - clear intent
it("prevents access to lesson without prerequisites");
it("calculates average score from multiple exercise submissions");
it("unlocks achievement when user completes 50 lessons");

// Bad - vague
it("works correctly");
it("test lesson access");
it("achievement test");

Service Layer Testing

At HelloC++, we separate business logic into services. This makes testing cleaner because business logic doesn't depend on HTTP, databases, or other infrastructure.

Testing Services

Services encapsulate business operations:

// tests/services/CourseProgressService.test.js
const CourseProgressService = require("../../services/CourseProgressService");
const {
    createUser,
    createCourse,
    createLesson
} = require("../helpers/factories");

describe("CourseProgressService", () => {
    let service;

    beforeEach(() => {
        service = new CourseProgressService();
    });

    it("calculates correct course completion percentage", async () => {
        const user = await createUser();
        const course = await createCourse();

        const lessons = await Promise.all(
            Array.from({ length: 10 }, () =>
                createLesson({ courseId: course.id })
            )
        );

        // Complete 3 out of 10 lessons
        for (let i = 0; i < 3; i++) {
            await UserProgress.create({
                userId: user.id,
                lessonId: lessons[i].id,
                status: "completed"
            });
        }

        const progress = await service.getCourseProgress(user.id, course.id);

        expect(progress.percentage).toBe(30);
        expect(progress.completed).toBe(3);
        expect(progress.total).toBe(10);
    });

    it("returns zero progress for unenrolled user", async () => {
        const user = await createUser();
        const course = await createCourse();

        const progress = await service.getCourseProgress(user.id, course.id);

        expect(progress.percentage).toBe(0);
        expect(progress.completed).toBe(0);
    });
});

Testing Complex Business Logic

// tests/services/SpeedExerciseService.test.js
const SpeedExerciseService = require("../../services/SpeedExerciseService");

describe("SpeedExerciseService", () => {
    it("calculates points based on time and accuracy", async () => {
        const service = new SpeedExerciseService();
        const user = await createUser();
        const session = await createSpeedSession({ userId: user.id });

        const result = await service.completeExercise({
            session,
            timeElapsed: 45, // seconds
            correctAnswers: 8,
            totalQuestions: 10
        });

        expect(result.points).toBeGreaterThan(0);
        expect(result.accuracy).toBe(80);
        expect(result.timeBonus).toBeGreaterThan(0);
    });

    it("awards no time bonus for slow completion", async () => {
        const service = new SpeedExerciseService();
        const session = await createSpeedSession();

        const result = await service.completeExercise({
            session,
            timeElapsed: 300, // 5 minutes
            correctAnswers: 10,
            totalQuestions: 10
        });

        expect(result.timeBonus).toBe(0);
    });
});

Protecting Test Data: Database Isolation

One critical safety feature in our test setup ensures test isolation:

// tests/setup.js
const { sequelize } = require("../database");

beforeAll(async () => {
    // Use test database
    if (process.env.NODE_ENV !== "test") {
        throw new Error(
            "DANGER: Tests must run in test environment!\n" +
                "Current environment: " +
                process.env.NODE_ENV +
                "\n" +
                "Set NODE_ENV=test to run tests safely."
        );
    }

    // Sync database schema
    await sequelize.sync({ force: true });
});

beforeEach(async () => {
    // Clear all tables before each test
    await sequelize.truncate({ cascade: true });
});

afterAll(async () => {
    await sequelize.close();
});

This guard ensures tests never run against production data. Always set the correct environment:

NODE_ENV=test npm test

Testing Best Practices

Arrange-Act-Assert Pattern

Structure tests consistently:

it("awards points for quiz completion", async () => {
    // Arrange - setup test data
    const user = await createUser();
    const quiz = await createQuiz({ points: 50 });

    // Act - perform the action
    await request(app)
        .post(`/quiz/${quiz.id}/submit`)
        .set("Authorization", `Bearer ${user.token}`)
        .send({ answers: { 1: "A", 2: "B" } });

    // Assert - verify outcome
    const updatedUser = await User.findByPk(user.id);
    expect(updatedUser.totalPoints).toBe(50);
});

Mock External Services

Don't make real API calls or external requests in tests:

it("compiles code via Docker", async () => {
    const mockDockerService = {
        compile: jest.fn().mockResolvedValue({
            success: true,
            output: "Hello, World!",
            exitCode: 0
        })
    };

    const codeService = new CodeExecutionService(mockDockerService);
    const result = await codeService.execute({
        code: "#include <iostream>\nint main() { return 0; }",
        language: "cpp"
    });

    expect(result.success).toBe(true);
    expect(mockDockerService.compile).toHaveBeenCalledTimes(1);
});

Test Error Conditions

Don't just test happy paths:

describe("Code Execution", () => {
    it("rejects code submission without authentication", async () => {
        const response = await request(app)
            .post("/api/code/execute")
            .send({ code: "int main() { return 0; }" });

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

    it("validates code length limit", async () => {
        const user = await createUser();
        const tooLongCode = "x".repeat(100000); // Exceeds limit

        const response = await request(app)
            .post("/api/code/execute")
            .set("Authorization", `Bearer ${user.token}`)
            .send({ code: tooLongCode });

        expect(response.status).toBe(422);
        expect(response.body.error).toMatch(/code.*too long/i);
    });

    it("handles compilation errors gracefully", async () => {
        const user = await createUser();
        const invalidCode = "this is not valid C++ code";

        const response = await request(app)
            .post("/api/code/execute")
            .set("Authorization", `Bearer ${user.token}`)
            .send({ code: invalidCode });

        expect(response.status).toBe(200);
        expect(response.body.success).toBe(false);
        expect(response.body.error).toBeDefined();
    });
});

Running Tests Efficiently

Run Specific Tests

Don't run the entire suite every time:

# Run specific file
npm test -- quizCompletion.test.js

# Run by pattern
npm test -- --testNamePattern="Quiz"

# Run tests in watch mode
npm test -- --watch

# Run with coverage
npm test -- --coverage

Coverage Analysis

Jest provides built-in coverage reporting:

npm test -- --coverage --coverageReporters=text lcov

We aim for >80% coverage on critical paths.

Pre-Push Hooks

Run tests before pushing:

# .git/hooks/pre-push
#!/bin/sh
npm run lint
npm test -- --bail --silent

if [ $? -ne 0 ]; then
    echo "Tests failed. Push aborted."
    exit 1
fi

Or use Husky for better hook management:

{
    "husky": {
        "hooks": {
            "pre-push": "npm run lint && npm test -- --bail"
        }
    }
}

Real-World Benefits

Since adopting TDD, we've seen:

Fewer Bugs in Production

  • ~60% reduction in production bugs in the last 6 months
  • Bugs caught during development, not by users
  • Faster bug fixes with tests demonstrating the issue

Faster Development

Counterintuitively, TDD makes us faster:

  • Less time debugging
  • Refactoring with confidence
  • Clear requirements before coding

Better Code Design

TDD forces us to think about:

  • API design (how will this be used?)
  • Dependencies (what does this need?)
  • Edge cases (what could go wrong?)

Living Documentation

Tests document how code should work:

it("prevents user from accessing premium content without subscription");
it("allows 3 free speed practice sessions per day");
it("resets daily concept streak when user misses a day");

Reading these tests tells you exactly what the system does.

Not Running Tests Frequently

TDD only works if you run tests constantly:

# Run tests in watch mode
npm test -- --watch

# Or configure your IDE for real-time feedback

Snapshot Testing

Jest's snapshot testing is powerful for UI components and API responses:

it("generates correct lesson card HTML", () => {
    const lesson = {
        title: "Variables and Types",
        duration: 15,
        difficulty: "beginner"
    };

    const html = renderLessonCard(lesson);

    expect(html).toMatchSnapshot();
});

When the structure changes intentionally, update snapshots:

npm test -- --updateSnapshot

Async Testing Best Practices

Jest handles async code elegantly:

// Using async/await
it("creates user account", async () => {
    const user = await authService.register({
        email: "test@example.com",
        password: "password123"
    });

    expect(user.id).toBeDefined();
});

// Testing promises
it("rejects invalid email", () => {
    return expect(authService.register({ email: "invalid" })).rejects.toThrow(
        "Invalid email"
    );
});

Conclusion

Test-Driven Development transformed how we build HelloC++. By writing tests first, we:

  • Catch bugs before users do
  • Refactor fearlessly
  • Document system behavior
  • Design better APIs
  • Ship faster with confidence

Combined with Jest's delightful syntax and powerful features, TDD becomes not just manageable but enjoyable.

Key takeaways:

  • Write tests first - Red, Green, Refactor
  • Use Jest's expressive syntax - Clean and readable
  • Test behavior, not implementation - Focus on outcomes
  • Leverage factory functions - Consistent test data
  • Protect production - Environment guards prevent disasters
  • Run tests constantly - Fast feedback loops
  • Mock external dependencies - Fast, isolated tests

If you're building a JavaScript application, invest in TDD. The initial learning curve pays dividends in code quality, developer confidence, and long-term maintainability.

Further Reading:

Questions About TDD?

Every team's testing journey is unique. If you have questions about implementing TDD or want to share your experiences, reach out - I'd love to hear from you.

Happy testing!

Support Free C++ Education

Help us create more high-quality C++ learning content. Your support enables us to build more interactive projects, write comprehensive tutorials, and keep all content free for everyone.

Become a Patron

About the Author

Imran Bajerai

Software engineer and C++ educator passionate about making programming accessible to beginners. With years of experience in software development and teaching, Imran creates practical, hands-on lessons that help students master C++ fundamentals.

Article Discussion

Share your thoughts and questions

💬

No comments yet. Be the first to share your thoughts!