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.
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:
- Red: Write a failing test
- Green: Write minimal code to make it pass
- 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:
- Jest Documentation
- Testing JavaScript
- Test-Driven Development by Kent Beck
- Growing Object-Oriented Software, Guided by Tests
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.
About the Author
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!