Or press ESC to close.

Mocked Up for Success: Bun 1.1's Testing Toolkit Gets a Makeover

Apr 21st 2024 12 min read
easy
bun1.1.4
javascriptES6
overview

Bun 1.1 arrived with a toolbox full of goodies! While it brought a range of enhancements, we're zooming in on the features that supercharge our testing experience in Bun. This post will unpack the powerful new assertion matchers and module mocking capabilities that put us in control and streamline our testing workflow.

Supercharged Assertions with New Matchers

Bun 1.1 brings a powerful arsenal of new assertion matchers to our testing toolkit. These matchers provide more expressive and readable ways to verify the expected behavior of our code. Let's explore some of these gems and see how they can elevate our tests:

1. .toBeObject(): This matcher confirms that the actual value is indeed an object:

                                     
test("object matcher", () => {
    const user = { name: "John", age: 30 };
    expect(user).toBeObject();
});
                    

2. .toBeEmptyObject(): Use this matcher to assert that an object is empty (has no properties).

                                     
test("empty object matcher", () => {
    const emptyObject = {};
    expect(emptyObject).toBeEmptyObject();
});
                    

3. .toContainKeys(): This matcher verifies that an object contains a specific set of keys.

                                     
test("keys matcher", () => {
    const person = { name: "Bob", age: 25 };
    expect(person).toContainKeys(["name", "age"]);
});
                    

4. .toContainAnyKeys(): In contrast, .toContainAnyKeys() checks if an object includes at least one of the provided keys.

                                     
test("any key matcher", () => {
    const data = { id: 1 };
    expect(data).toContainAnyKeys(["id", "name"]);
});
                    

5. .toContainEqual(): This matcher comes in handy when we want to assert that an array contains an item with a specific structure and matching values. It excels at testing individual elements within the array by recursively checking the equality of all fields within the item itself.

                                     
const students = [
    { name: 'Alice', age: 20, grade: 'A' },
    { name: 'Bob', age: 22, grade: 'B' },
    { name: 'Charlie', age: 21, grade: 'C' }
];
                      
test('contains a student with specific details', () => {
    const targetStudent = { name: 'Alice', age: 20, grade: 'A' };
    expect(students).toContainEqual(targetStudent);
});
                    

6. .toContainKey(): This matcher simply checks if an object has a specific key.

                                     
test("key existence matcher", () => {
    const product = { name: "T-Shirt", price: 19.99 };
    expect(product).toContainKey("price");
});
                    

7. .toHaveBeenCalledWith() confirms if our mock function was called with the exact arguments we expect.

                                     
function addAndCall(a, b, callback) {
    const sum = a + b;
    callback(sum);
}
                          
test('calls the callback with the correct sum', () => {
    const mockCallback = mock();
    addAndCall(2, 3, mockCallback);
    expect(mockCallback).toHaveBeenCalledWith(5);
});
                    

8. .toHaveBeenLastCalledWith() lets us zoom in on the final call of a mock function, verifying if it received the exact arguments we expect.

                                     
test('updates log with the correct message', () => {
    const mockUpdateLog = mock();
                          
    // Simulate updating the log with different messages
    mockUpdateLog('Message 1');
    mockUpdateLog('Message 2');
    mockUpdateLog('Message 3');
                          
    // Check if updateLog was last called with the correct message
    expect(mockUpdateLog).toHaveBeenLastCalledWith('Message 3');
});
                    

9. .toHaveBeenNthCalledWith() allows us to target specific invocations of a mock function. We provide the call number (nthCall) and the expected arguments to ensure the function was called with that data at the specified position in the call sequence.

                                     
// Function to apply discount and log discounted price
function applyDiscount(item, discount) {
    const discountedPrice = calculateDiscountedPrice(item, discount);
    console.log(`Discounted price for ${item}: $${discountedPrice}`);
}
                        
// Function to calculate discounted price
function calculateDiscountedPrice(item, discount) {
    // Logic to calculate discounted price (not shown in this example)
    return 100 - discount; // Dummy logic for demonstration
}
                        
test('applies discount correctly and logs discounted prices', () => {
    const mockApplyDiscount = mock(applyDiscount); // Mock the actual function
                        
    // Simulate applying discount to multiple items
    mockApplyDiscount('Laptop', 10);
    mockApplyDiscount('Phone', 20);
    mockApplyDiscount('Tablet', 15);
                        
    expect(mockApplyDiscount).toHaveBeenNthCalledWith(1, 'Laptop', 10);
    expect(mockApplyDiscount).toHaveBeenNthCalledWith(2, 'Phone', 20);
    expect(mockApplyDiscount).toHaveBeenNthCalledWith(3, 'Tablet', 15);
});
                    

10. .toEqualIgnoringWhitespace() lets us compare strings for equality, ignoring variations in whitespace characters like spaces, tabs, and newlines.

                                     
test('whitespace ignored matcher', () => {
    expect('The Green Report').toEqualIgnoringWhitespace('The Green Report')
    expect(' The Green Report ').toEqualIgnoringWhitespace('The Green Report')
    expect('T he Gr ee n Rep ort').toEqualIgnoringWhitespace('The Green Report')
    expect('The Gre\nen Report').toEqualIgnoringWhitespace('The Green Report')
});
                    

11. The expect.unreachable() function indicates that the code shouldn't ever reach that point in the program's execution. It's typically used for two main purposes: error handling and unreachable code.

                                     
function getUserRole(isAdmin) {
    if (isAdmin) {
        return "admin";
    } else {
        // This path shouldn't be reached if input validation is proper
        expect.unreachable("Non-admin user should not reach this point");
        return "invalid"; // This line would never execute
    }
}
                          
// Simulate proper input
console.log(getUserRole(true)); // Output: "admin"
                          
// Simulate unexpected input (for demonstration purposes only)
console.log(getUserRole(undefined)); // This will throw an error
                    

12. The expect.arrayContaining() method is used to verify that an array contains all the elements of another array, regardless of the order or presence of additional elements. This is helpful for situations where we expect a specific subset of elements but don't care about the exact structure of the entire array.

                                     
// This function simulates adding items to a shopping cart
function addToCart(item) {
    return [...cart, item];
}
                        
// Our initial shopping cart
let cart = [];
                        
test("Cart contains expected items", () => {
    cart = addToCart("apples");
    cart = addToCart("bread");
    cart = addToCart("milk");
                        
    // Expected items in the cart (doesn't matter about the order)
    const expectedItems = ["bread", "milk"];
                        
    // Assert that the cart contains all the expected items
    expect(cart).toEqual(expect.arrayContaining(expectedItems));
});
                    

13. The expect.objectContaining() method allows us to verify that an object contains a specific set of properties, regardless of any additional properties it might have. This is useful when we only care about certain aspects of an object's structure, not its entire content.

                                     
// Function to process user data (simulates adding properties)
function processUserData(data) {
    return {
        ...data,
        userId: Math.random().toString(36).substring(2, 15), // Generate a random ID
    };
}
                        
// Test processing user data
test("Processed user data contains required fields", () => {
    const userData = {
        name: "Jane",
        email: "jane@example.com",
    };
                        
    const processedData = processUserData(userData);
                        
    // Expected properties in the processed data
    const expectedProperties = {
        name: expect.any(String), // We only care it's a string
        email: expect.any(String),
    };
                        
    // Assert that processed data contains required properties
    expect(processedData).toEqual(expect.objectContaining(expectedProperties));
});
                    

14. The expect.closeTo() matcher helps us compare floating-point numbers with a certain level of precision. This is useful because floating-point calculations can sometimes introduce slight inaccuracies.

                                     
test("compare floating-point numbers in object properties with close precision", () => {
    const calculatedSum = 0.1 + 0.2;
    const expectedSum = 0.3;
                          
    expect({
        title: "0.1 + 0.2",
        sum: calculatedSum,
    }).toEqual({
        title: "0.1 + 0.2",
        sum: expect.closeTo(expectedSum, 5), // Compare with a precision of 5 decimal digits
    });
});
                    

15. expect.extend allows us to add custom matchers to Bun for more specific assertions in our tests.

                                     
// Define a custom matcher to check if a number is close to zero within a specified precision
function toBeCloseToZero(actual, precision = 2) {
    if (
        typeof actual !== "number" ||
        typeof precision !== "number" ||
        precision < 0
    ) {
        throw new TypeError("Arguments must be of type number!");
    }
                        
    const pass = Math.abs(actual) < Math.pow(10, -precision) / 2;
    if (pass) {
        return {
            message: () =>
                `expected ${actual} not to be close to zero within precision ${precision}`,
            pass: true,
        };
    } else {
        return {
            message: () =>
                `expected ${actual} to be close to zero within precision ${precision}`,
            pass: false,
        };
    }
}
                        
// Extend the expect object with the custom matcher
expect.extend({
    toBeCloseToZero,
});
                        
// Test cases
test("is close to zero within default precision", () => {
    expect(0.0001).toBeCloseToZero();
});
                        
test("is close to zero within custom precision", () => {
    expect(0.0001).toBeCloseToZero(undefined, 4); // Pass undefined as the first argument to skip it
});
                        
test("is NOT close to zero within default precision", () => {
    expect(0.1).not.toBeCloseToZero();
});
                    

Mastering the Art of Module Mocking

Module mocking is a powerful technique in testing that allows us to replace real modules with simulated versions during test execution. This is particularly useful for isolating specific parts of our code and testing them independently of external dependencies. Here's how Bun 1.1 empowers us with module mocking:

Benefits of Module Mocking:

Bun's Module Mocking:

Bun's mock.module() function facilitates module mocking for both ES modules (using import) and CommonJS modules (using require). Here's a breakdown of its usage:

                                     
import { mock } from "bun:test";

// Mocking an ES module:
mock.module("./myModule", () => {
    return {
        // Provide mocked functionality here
    };
});
                        
// Mocking a CommonJS module:
const realModule = require("./myModule");
mock.module("./myModule", () => {
    return {
        // Provide mocked functionality here
    };
});
                    

mock.module(path, callback) takes two arguments:

Bun's module mocking works seamlessly with both ES modules and CommonJS modules. We can use either import or require to interact with the mocked module within our test.

Auto-Mocking (Not Yet Supported):

As of Bun 1.1, auto-mocking (automatically mocking any imported module) is not yet supported. However, we can achieve a similar effect by using the --preload flag when running our tests:

                                     
bun test --preload ./my-preload.ts
                    

In my-preload.ts, we can define our mocks using mock.module(), ensuring they are loaded before any tests run.

Conclusion

Bun 1.1 brings a powerful arsenal of new features to streamline our workflow. With the expressive assertion matchers and robust module mocking capabilities, we can write more confident and targeted tests that isolate specific behaviors.

While Bun 1.1 focuses on enhancing the testing experience itself, using Bun for testing offers additional benefits:

We've only scratched the surface of Bun 1.1's testing improvements. Dive deeper into the documentation and explore the new features to elevate your testing practices.

All code examples mentioned above, plus some additional ones, are readily available on our GitHub page.