HowtoTestYourNextJS14ApplicationswithBun
2024-03-25
Jack McGregor
Welcome to our guide on writing unit and integration tests for your Next.js 14 application paired with Tailwind CSS, leveraging the power of Bun.js, HappyDom, and React Testing Library. Testing is a crucial aspect of software development, ensuring the reliability and stability of your codebase. In this walkthrough, we'll be using Bun’s integrated test runner to run a few light examples of unit tests and integration tests.
Bun is the new kid on the Node block. Built with Zig and having recently released v1.0 back in September 2023, Bun has been attracting Node developers for its lighting-fast execution and out-of-the-box Node compatibility. While it’s not fully integrated yet (i.e. many matchers and tools used in Node aren’t available in Bun), it’s already proven to be useful at speeding up aspects of development, mainly testing.
In this example, we’ll build a very simple UI, using form input to change the text and style of a paragraph. We’ll write a couple of unit tests for the smaller, simple functions and then use HappyDom and React Testing Library to write a few integration tests. It’s nice to be able to use Bun’s test runner without having to install and setup test runners like Jasmine, Jest etc. And did I mention it’s fast?
Demo Repo
You can see the demo repo we created with the code used in this guide here: https://github.com/Antler-Digital/next14-bun-testing
Installation
- Install Bun
# https://bun.sh/
curl -fsSL https://bun.sh/install | bash
- Create a basic NextJS app with Typescript and install the required testing packages
# IN TERMINAL
# typescript, eslint, src, app
➜ bun create next-app
➜ bun add -d @happy-dom/global-registrator @testing-library/react @types/bun
- Happy DOM needs to load before
bun test
is triggered so we need to register it then load it into the test environment
➜ touch happydom.ts bunfig.toml
➜ rm src/app/page.module.css
// happydom.ts
import { GlobalRegistrator } from "@happy-dom/global-registrator";
GlobalRegistrator.register();
# bunfig.toml
[test]
preload = "./happydom.ts"
- We’re using TailwindCSS to style the app, so we need to install and configure it. Fortunately, Tailwind makes this process very easy
# add TailwindCSS - https://tailwindcss.com/docs/guides/nextjs
➜ bun add -D tailwindcss postcss autoprefixer
➜ bunx tailwindcss init -p
➜ bun install
- Next, copy and paste the following code into
src/app/page.tsx
. It’s a super simple UI that uses a select input and two buttons to change the text and its colour. We won’t be editing it.
// src/app/page.tsx
"use client";
import { useMemo, useState } from "react";
import { generateColor } from "../lib/generate-color";
import { Tickers } from "../../types";
import { isTicker } from "../lib/type-helpers";
export default function Home() {
const [selectedTicker, setSelectedTicker] = useState<Tickers>("AAPL");
const [textColor, setTextColor] = useState("gray");
const tickerMap = useMemo(
() => ({
AAPL: (
<p style={{ color: textColor }} data-testid="aapl">
An apple a day keeps the doctor away
</p>
),
GOOGL: (
<p style={{ color: textColor }} data-testid="googl">
Google it
</p>
),
AMZN: (
<p style={{ color: textColor }} data-testid="amzn">
Amazing
</p>
),
}),
[textColor]
);
return (
<main className="p-20 flex gap-20 min-h-screen h-full bg-zinc-800 items-center justify-center">
<section className="p-10 min-h-80 bg-emerald-400 shadow-lg w-1/2">
<div className="flex gap-x-20 h-full">
<div className="w-1/2 space-y-2">
<div className="w-full">
<h2 className="font-bold text-2xl">Input</h2>
<label htmlFor="ticker">
<span>Ticker</span>
{/* options */}
<select
name="ticker"
id="ticker"
className="p-2 w-full"
data-testid="select"
onChange={(e) => {
if (isTicker(e.target.value)) {
setSelectedTicker(e.target.value);
}
}}
>
<option value="default" disabled>
Select one
</option>
<option value="AAPL" data-testid="select-option">
AAPL
</option>
<option value="GOOGL" data-testid="select-option">
GOOGL
</option>
<option value="AMZN" data-testid="select-option">
AMZN
</option>
</select>
</label>
</div>
<div className="w-full">
<label htmlFor="">
<span>Change color</span>
<div className="w-full flex gap-x-4">
<button
type="button"
className="bg-blue-500 p-2 text-white rounded-md w-full"
onClick={() => {
const randomColor = generateColor("text");
setTextColor(randomColor);
}}
>
By name
</button>
<button
type="button"
className="bg-gray-500 p-2 text-white rounded-md w-full"
onClick={() => {
const randomColor = generateColor("hex");
setTextColor(randomColor);
}}
>
By hex
</button>
</div>
</label>
</div>
</div>
<div className="w-1/2 space-y-2">
<h2 className="font-bold text-2xl">Result</h2>
{/* result */}
<div className="text-4xl flex flex-col justify-between h-full">
<div>{tickerMap[selectedTicker]}</div>
<div className="text-lg">
Color:{" "}
<span className="font-bold" data-testid="text-color">
{textColor}
</span>
</div>
</div>
</div>
</div>
</section>
</main>
);
}
- Finally, let’s spin up the app and run the tests. The tests should fail
➜ bun test
➜ bun run dev
- When you navigate to http://localhost:3000/ you should see the following
Lib + Unit tests
We have two helper functions in our src/lib
directory that we need to write tests for:
src/lib/generate-color.ts
// src/lib/generate-color.ts
export const COLORS = ["red", "blue", "green", "yellow", "purple"];
export const generateColor = (type: "text" | "hex" = "text") => {
if (type === "hex") {
// generate hex color
const hexColor = "#" + Math.floor(Math.random() * 16777215).toString(16);
return hexColor;
}
const randomColor = COLORS[Math.floor(Math.random() * COLORS.length)];
return randomColor;
};
It’s always good to aim for 100% code coverage, though its important to note that it’s not always possible or necessary. For this function, we want to test that changing the type
parameter from text
to hex
will yield different results.
// src/lib/tests/generate-color.test.ts
import { expect, describe, it } from "bun:test";
import { COLORS, generateColor } from "../generate-color";
describe("generateColor", () => {
it("should return a color", () => {
const color = generateColor();
// the color should be defined
expect(color).toBeDefined();
// the color should be a specific string
expect(color).toMatch(/red|blue|green|yellow|purple/);
// the color should be one of the predefined colors
expect(COLORS).toContain(color);
});
it("should return a hex color", () => {
const color = generateColor("hex");
expect(color).toMatch(/^#[0-9A-F]{6}$/i);
});
});
src/lib/type-helpers.ts
This function simply verifies that an input is valid. We’ve extracted the logic into a resuable function using Typescript’s “type predicate”, which returns a boolean which, if true, casts the return value as that type.
// src/lib/type-helpers.ts
import { Tickers } from "../../types";
export function isTicker(ticker: string): ticker is Tickers {
return ["AAPL", "GOOGL", "AMZN"].includes(ticker);
}
In this test, we can create a testing array with a value and an expected result, then loop over the array with an expect
// src/lib/tests/type-helpers.test.ts
import { expect, describe, it } from "bun:test";
import { isTicker } from "../type-helpers";
const tickers = [
{
value: "AAPL",
expected: true,
},
{
value: "GOOGL",
expected: true,
},
{
value: "AMZN",
expected: true,
},
{
value: "MSFT",
expected: false,
},
{
value: "TSLA",
expected: false,
},
{
value: "FB",
expected: false,
},
];
describe("isTicker", () => {
it("should validate tickers", () => {
tickers.forEach(({ value, expected }) => {
expect(isTicker(value)).toBe(expected);
});
});
});
Now when the run the test:
➜ bun test
bun test v1.0.31 (9573c6e2)
src/lib/tests/type-helpers.test.ts:
✓ isTicker > should validate tickers [0.50ms]
src/lib/tests/generate-color.test.ts:
✓ generateColor > should return a color [0.21ms]
✓ generateColor > should return a hex color [0.03ms]
Integration tests with React Testing Library
Testing UIs can be challenging because there are many ways a user can interact with elements. There are two main ways to automate UI testing in Front Development:
- Integration testing using libraries like React Testing Library or Enzyme
- End to End testing using libraries like Playwright
Since we’ve loaded Happy DOM, React Testing Library will have access to the DOM in the testing environment. Using RTL’s screen
and render
methods, we can find elements rendered by our component, interact with them and get their values.
An important thing to note in the examples below is the difference between get[ByText]
, find[ByText]
and query[ByText]
get
will throw if an element isn’t foundquery
will returnnull
if an element isn’t foundfind
will return a Promise
We’ve also bundled up some common expects
into a reusable function so each test can at least test that all basic elements are present and accounted for.
With Happy DOM + RTL, it’s necessary to clear the DOM before each test in our beforeEach
.
// __tests__/page.test.tsx
import { expect, describe, it, beforeEach } from "bun:test";
import { act, fireEvent, render, screen } from "@testing-library/react";
import MainPage from "../src/app/page";
// helper to test the common elements of the page every time
function testCommonElements() {
// input half
expect(screen.getByRole("heading", { name: /input/i })).not.toBeNull();
expect(screen.getByText(/ticker/i)).not.toBeNull();
expect(screen.getByText(/change color/i)).not.toBeNull();
expect(
screen.getByRole("button", {
name: /by name/i,
})
).not.toBeNull();
expect(
screen.getByRole("button", {
name: /by hex/i,
})
).not.toBeNull();
// result half
expect(screen.getByRole("heading", { name: /result/i })).not.toBeNull();
expect(screen.getByText(/color:/i)).not.toBeNull();
// initial state
expect(
screen.getByRole("option", {
name: /aapl/i,
selected: true,
})
).not.toBeNull();
expect(
screen.getByRole("option", {
name: /googl/i,
selected: false,
})
).not.toBeNull();
expect(
screen.getByRole("option", {
name: /amzn/i,
selected: false,
})
).not.toBeNull();
expect(
screen.getByText(/an apple a day keeps the doctor away/i)
).not.toBeNull();
}
describe("main page", () => {
beforeEach(() => {
// clear the document body
document.body.innerHTML = "";
});
it("should render all elements without throwing", () => {
expect(() => render(<MainPage />)).not.toThrow();
testCommonElements();
});
describe("change text", () => {
it("should change the text to GOOGL", () => {
render(<MainPage />);
testCommonElements();
// GOOGL is NOT selected
expect(
screen.getByRole("option", {
name: /googl/i,
selected: false,
})
).not.toBeNull();
expect(screen.queryByText(/google it/i)).toBeNull();
// change the select value
fireEvent.change(screen.getByTestId("select"), {
target: { value: "GOOGL" },
});
// GOOGL IS selected
expect(
screen.getByRole("option", {
name: /googl/i,
selected: true,
})
).not.toBeNull();
expect(screen.queryByText(/google it/i)).not.toBeNull();
});
it("should change the text to AMZN", () => {
render(<MainPage />);
testCommonElements();
// AMZN is NOT selected
expect(
screen.getByRole("option", {
name: /amzn/i,
selected: false,
})
).not.toBeNull();
expect(screen.queryByText(/amazing/i)).toBeNull();
// change the select value
fireEvent.change(screen.getByTestId("select"), {
target: { value: "AMZN" },
});
// AMZN IS selected
expect(
screen.getByRole("option", {
name: /amzn/i,
selected: true,
})
).not.toBeNull();
expect(screen.queryByText(/amazing/i)).not.toBeNull();
});
});
describe("change color", () => {
it("change color by name", () => {
render(<MainPage />);
testCommonElements();
// initial color
const defaultColor = screen.getByTestId("text-color")
.textContent as string;
expect(screen.getByTestId("aapl").style.color).toBe(defaultColor);
// change color
fireEvent.click(
screen.getByRole("button", {
name: /by name/i,
})
);
const newColor = screen.getByTestId("text-color").textContent as string;
// new color
expect(screen.getByTestId("aapl").style.color).toBe(newColor);
});
it("change color by hex", () => {
render(<MainPage />);
testCommonElements();
// initial color
const defaultColor = screen.getByTestId("text-color")
.textContent as string;
expect(screen.getByTestId("aapl").style.color).toBe(defaultColor);
// change color
fireEvent.click(
screen.getByRole("button", {
name: /by hex/i,
})
);
const newColor = screen.getByTestId("text-color").textContent as string;
// new color
expect(screen.getByTestId("aapl").style.color).toBe(newColor);
});
});
});
In the above example, we’re first testing the initial render.
Next, we test the text changes when the select is changed by using fireEvent.change
. It’s as important to test that elements aren’t visible as testing when elements are, to make sure our UI only contains what we want, when we want it.
Finally, we’re testing that the colour of our text is changing when we click one of the two buttons. Our UI has the colour name / hex underneath which we can use to verify if the CSS has changed.
Now when we run our tests (this time using the --coverage
flag) you should see the following:
bun test v1.0.31 (9573c6e2)
__tests__/page.test.tsx:
✓ main page > change text > should change the text to GOOGL [38.15ms]
✓ main page > change text > should change the text to AMZN [13.87ms]
✓ main page > change color > change color by name [10.76ms]
✓ main page > change color > change color by hex [9.69ms]
✓ main page > should render all elements without throwing [6.33ms]
src/lib/tests/type-helpers.test.ts:
✓ isTicker > should validate tickers [1.14ms]
src/lib/tests/generate-color.test.ts:
✓ generateColor > should return a color [0.13ms]
✓ generateColor > should return a hex color [0.04ms]
---------------------------|---------|---------|-------------------
File | % Funcs | % Lines | Uncovered Line #s
---------------------------|---------|---------|-------------------
All files | 100.00 | 100.00 |
happydom.ts | 100.00 | 100.00 |
src/app/page.tsx | 100.00 | 100.00 |
src/lib/generate-color.ts | 100.00 | 100.00 |
src/lib/type-helpers.ts | 100.00 | 100.00 |
---------------------------|---------|---------|-------------------
8 pass
0 fail
78 expect() calls
Ran 8 tests across 3 files. [415.00ms]
Bonus: CICD using Github Actions
Create a new file and directory:
.github/workflows/build-and-test.yml
Now copy paste the following code
name: Bun test demo
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build-and-test:
name: Build and test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- run: bun install
- run: bun test
- run: bun run build
Now, whenever you push your code up, This action will checkout the official Bun action from Github and install, test and build your code! Voila!
Lets grow your business together
At Antler Digital, we believe that collaboration and communication are the keys to a successful partnership. Our small, dedicated team is passionate about designing and building web applications that exceed our clients' expectations. We take pride in our ability to create modern, scalable solutions that help businesses of all sizes achieve their digital goals.
If you're looking for a partner who will work closely with you to develop a customized web application that meets your unique needs, look no further. From handling the project directly, to fitting in with an existing team, we're here to help.