Test-Driven Development (TDD) is a powerful methodology for writing clean, bug-free code. In this article, we’ll explore how to implement TDD using Bun’s built-in test runner, Bun Test, which is known for its speed and simplicity.
What is TDD?
Test-driven development (TDD) is a software development practice in which tests are written before the code. The TDD practice guides the implementation and ensures functionality through iterative writing, testing, and refactoring cycles.
TDD is a development process that follows these steps:
- Write a test for the desired functionality.
- Define all the testing scenarios you want to cover.
- Run the test and verify that it fails (as the functionality might be incomplete or not cover all scenarios yet).
- Update and refactor the code to make the test pass while ensuring all tests succeed.
This iterative process is designed to produce robust and well-tested code.
Setting up your JavaScript project with Bun
If you haven’t installed Bun, install it by following the instructions at the Bun JavaScript documentation.
Then, initialize a new project:
bun init
➜ example bun init
bun init helps you get started with a minimal project and tries to guess sensible defaults. Press ^C anytime to quit
package name (example):
entry point (index.ts):
Done! A package.json file was saved in the current directory.
Create a test file in the tests
directory (e.g., tests/example.test.js
). Bun automatically detects files ending with .test.ts
or .test.js
for testing.
mkdir tests
touch tests/example.test.js
Writing your first test
Let’s start with a simple example.
We’ll create a calculator file to implement some mathematical functions.
We’ll first focus on a simple function, like sum()
, even though JavaScript already has a native addition operator. This allows us to concentrate on structuring the tests rather than the complexity of the logic.
Here’s the plan:
- Create a
calculator.ts
file where we’ll define asum()
function that initially returns0
. - Write tests for the
sum()
function, covering several test cases. - Run the tests and confirm that they fail.
- Update the logic of the
sum()
function to make the tests pass. - Rerun the tests to ensure our implementation is correct.
Create your calculator.test.js
file
In the tests/calculator.test.js
file, you can implement your tests:
import { describe, expect, it } from "bun:test";
import { sum } from "../calculator";
describe("sum function", () => {
it("should return the sum of two numbers (both are positive)", () => {
expect(sum(2, 3)).toBe(5);
});
it("should return the sum of two numbers (one is negative)", () => {
expect(sum(-1, 2)).toBe(1);
});
});
These tests verify the behavior of the sum()
function, defined in the calculator
module. The tests are written using Bun's testing library and organized within a describe
block named "sum function". The describe()
block helps to group "similar" tests. Each it()
block specifies a particular scenario to test. Here's what each test does:
- Test: Adding two positive numbers
- Description: "should return the sum of two numbers (both are positive)"
- This test checks if the sum function correctly calculates the sum of two positive integers.
- Example:
sum(2, 3)
is expected to return5
.
- Test: Adding a negative and a positive number
- Description: "should return the sum of two numbers (one is negative)"
- This test validates that the sum function correctly handles a scenario where one number is negative.
- Example:
sum(-1, 2)
is expected to return1
.
These tests ensure that the sum function behaves as expected for basic addition scenarios, covering both positive numbers and mixed (positive and negative) inputs.
Create your calculator.ts
file
Now, you can create your calculator module that will export the sum()
function.
In the calculator.ts
file:
export function sum(a: number, b: number) {
// Function yet to be implemented
return 0;
}
The first version of the function returns a hardcoded value, so I expect the tests to fail.
Running the tests:
bun test
Now we can adjust the logic of the sum()
function in the calculator.ts
adjust the logic of the sum()
function:
export function sum(a: number, b: number) {
return a + b;
}
Now, if you run the tests, you will have a "green" ✅ status.
Refactoring tests with dataset
If you want to run the same tests with different scenarios (input values), you can use the each()
method.
import { describe, expect, it } from "bun:test";
import { sum } from "../calculator";
const dataset = [
[2, 3, 5],
[-1, 2, 1],
];
describe("sum function", () => {
it.each(dataset)("Sum of %d and %d should be %d", (a, b, expected) => {
expect(sum(a, b)).toBe(expected);
});
});
Using a dataset-driven approach, this code tests the sum function from the calculator
module. The it.each()
method is employed to simplify repetitive test cases by iterating over a dataset of inputs and expected outputs. Here's a breakdown of how it works:
First, you can define a dataset
const dataset = [
[2, 3, 5], // Test case 1: Adding 2 and 3 should return 5
[-1, 2, 1], // Test case 2: Adding -1 and 2 should return 1
];
The dataset
is an array of arrays. Each inner array represents a test case, where the elements correspond to:
- a (first number to add),
- b (second number to add),
- expected (the expected result of sum(a, b)).
The describe
function groups all tests related to the sum function under a single block for better organization.
In the describe()
block, it.each(dataset)
iterates over each row in the dataset array.
"Sum of %d and %d should be %d"
is a description template for the test, where %d
is replaced with the actual numbers from the dataset during each iteration.
For example, the first iteration generates the description: "Sum of 2 and 3 should be 5".
In the callback function (a, b, expected)
, the elements of each row in the dataset are destructured into variables: a, b, and expected. Then, inside the test, the sum
function is called with a and b, and the result is checked using expect()
to ensure it matches the expected.
Why use it.each()
(or test.each()
)?
- Efficiency: instead of writing separate
it()
ortest()
blocks for each case, you can define all test cases in a single dataset and loop through them. - Readability: the test logic is concise, and the dataset makes adding or modifying test cases easy without duplicating code.
- Scalability: useful when dealing with multiple test cases, especially when the logic being tested is similar across cases.
Another practical example: calculating the mean
To show an additional example for TDD, let’s implement a mean function in the calculator module that calculates the mean (average) of an array of numbers. Following the TDD approach, we’ll start by writing the tests.
In the already existent calculator.test.js
add these tests specific for mean()
function:
const datasetForMean = [
[ [ 1, 2, 3, 4, 5] , 3],
[ [], null ],
[ [ 42 ] , 42 ],
];
describe("mean function", () => {
it.each(datasetForMean)("Mean of %p should be %p",
(
values,
expected
) => {
expect(mean(values)).toBe(expected);
});
});
Now in the calculator.ts
file, add the mean()
function:
export function mean(data: number[]) {
const count = data.length;
if (count === 0) {
return null;
}
const sum = data.reduce((total: number, num: number) => total + num, 0);
return sum / count;
}
So now you can execute again the tests
bun test
All the tests should pass.
In this case, the implementation is already tested, so no further refactoring is needed. However, always take the time to review your code for improvements.
Test coverage
Test coverage is a metric that measures the percentage of your codebase executed during automated tests. It provides insights into how well your tests validate your code.
The Bun test coverage helps to identify the "line coverage".
The line coverage checks whether each line of code is executed during the test suite.
Running the test coverage:
bun test --coverage
Why is Coverage Important?
- Identifying gaps in tests: coverage reports highlight which parts of your code are not tested. This helps you ensure critical logic isn’t overlooked.
- Improving code quality: high coverage ensures that edge cases, error handling, and business logic are tested thoroughly, reducing the likelihood of bugs.
- Confidence in refactoring: if you have a well-tested codebase, you can refactor with confidence, knowing your tests will catch regressions.
- Better maintenance: a codebase with high test coverage is easier to maintain, as you can detect unintended changes or side effects during updates.
- Supports TDD: for developers practicing Test-Driven Development, monitoring coverage ensures the tests align with implementation.
Balancing coverage goals
While high test coverage is important, it's not the only measure of code quality. Aim for meaningful tests focusing on functionality, edge cases, and critical parts of your application. Achieving 100% coverage is ideal, but not at the cost of writing unnecessary or trivial tests.
Conclusion
Test-Driven Development (TDD) with Bun Test empowers developers to write clean, maintainable, and robust code by focusing on requirements first and ensuring functionality through iterative testing. By leveraging Bun's fast and efficient testing tools, you can streamline your development process and confidently handle edge cases. Adopting TDD not only improves code quality but also fosters a mindset of writing testable, modular code from the start. Start small, iterate often, and let your tests guide your implementation.
Top comments (0)