Your browser does not support JavaScript! This site works best with javascript ( and by best only ).How to Test Your NextJS 14 Applications with Bun | Antler Digital

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

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 found
  • query will return null if an element isn’t found
  • find 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!

if (valuable) then share();

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.

How far could your business soar if we took care of the tech?

Copyright 2024 Antler Digital