DEV Community

Cover image for Test-Driven Development (TDD) with Bun Test
Roberto B.
Roberto B.

Posted on

Test-Driven Development (TDD) with Bun Test

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
Enter fullscreen mode Exit fullscreen mode
➜  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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 a sum() function that initially returns 0.
  • 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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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 return 5.
  2. 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 return 1.

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;
}
Enter fullscreen mode Exit fullscreen mode

The first version of the function returns a hardcoded value, so I expect the tests to fail.
Running the tests:

bun test
Enter fullscreen mode Exit fullscreen mode

Executing the  raw `bun test` endraw  command with failed tests

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;
}
Enter fullscreen mode Exit fullscreen mode

Now, if you run the tests, you will have a "green" ✅ status.

Executing the  raw `bun test` endraw  command with passed tests

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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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
];
Enter fullscreen mode Exit fullscreen mode

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() or test() 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);
  });
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

So now you can execute again the tests

bun test
Enter fullscreen mode Exit fullscreen mode

Executing 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
Enter fullscreen mode Exit fullscreen mode

Image description

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)