Automate build tests before pushing to GitHub with Vitest and Husky

Save time and resources by automating Next JS builds locally ahead of deployment.

Vitest and Husky logos

As I have been implementing more and more features onto this site, I have hit a repeated issue with my CI/CD setup... Occasionally, when I deploy to Vercel I'm hit with the dreaded deployment failure.

Now I'm not perfect and sometimes it's user error but, there are also a number of times that I have pushed my code to my development branch only for it to be deployed to Vercel where the build fails.

Ensuring builds pass locally

Immediately, you are probably thinking, this is an easy fix, run and test builds locally before a deployment to catch and fix any errors. You would be absolutely correct, that is the way to go. Don't send anything to the staging or production servers until you are confident.

In this article I'm going to show one method I have found really useful to run tests locally automatically during a git push (or technically, just before one).

Prerequisites

To automate our builds locally we will need a test runner and a way to hook into the Git lifecycle. Our goal is that when a user pushes to a deployment branch a build is run locally and tests are run against the build to ensure it completes successfully. Failed builds then prevent the push completing.

For test running I will be using Vitest, simply because I wanted an excuse to try it out, but you can use whatever testing framework you are familiar with, there is no inherent benefit to Vitest.

To hook into our git workflow and automate test running, we will be using Husky 🐶. Husky is a popular Git hook management tool for JavaScript and TypeScript projects. It simplifies the process of setting up and managing Git hooks, which are scripts that Git runs automatically at specific points in its workflow, such as before a commit or push.

Writing build tests with Vitest

We need to begin by install Vitest into our application. *This guide was written for Vitest 3.0.6.

npm install -D vitest

Once installed we can write our build test. I always like to create a folder called tests in the root of my project to keep all my tests in one place.

mkdir tests

Next, create our build test file:

touch tests/build.test.ts

In order to run our build within a test, we need to import some dependencies from Node and from Vitest.

build.test.ts
import { exec } from 'child_process'
import { describe, it, expect } from 'vitest'

exec allows us to run npm commands like npm run build and we import describe, it and expect from Vitest which we need to write our test case.

Creating a test function to run commands

With access to an exec command, we can write a function that handles this for us, and can be re-used in other tests.

const runCommand = (command: string) => {
  return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
    exec(command, (error, stdout, stderr) => {
      if (error) {
        reject({ stdout, stderr })
      } else {
        resolve({ stdout, stderr })
      }
    })
  })

Writing a build test

Even if you aren't familiar with Vitest, the code is written in a way that's very intuitive to understand and for those familiar with Jest or other testing suites, this will be quite familiar syntax:

describe('NextJS Build Process', () => {
  it('should build the project successfully', async () => {
    try {
      const { stdout } = await runCommand('npm run build')
      console.log(stdout)
      expect(true).toBe(true) // If no error occurs, the test passes
    } catch (error) {
      if (error instanceof Error) {
        console.error(error.message)
      } else {
        console.error(error)
      }

      expect(true).toBe(false) // Fail the test if an error occurs
    }
  })
})

We begin by describing our test, followed by an assertion; that it should build the project successfully. By writing assertions and descriptions like this in plain English, it makes test results much easier to understand.

The assertion is set to run asynchronously due to the runCommand function returning a Promise, and within the body of our assertion we utilise a try/catch block to run our build command and check if there are any errors.

Within the Try block, we log the output to the console. This is really useful while setting up this test but can be quite noisy in a production environment.

We try to catch any errors within the catch block and then check if the error is an instance of Error to grab the Error.message. If the error is more generic and not an Error instance, we use the if/else to catch and log to console.

The full build test should look like this:

import { exec } from 'child_process'
import { describe, it, expect } from 'vitest'

// Helper to run shell commands
const runCommand = (command: string) => {
  return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
    exec(command, (error, stdout, stderr) => {
      if (error) {
        reject({ stdout, stderr })
      } else {
        resolve({ stdout, stderr })
      }
    })
  })
}

describe('NextJS Build Process', () => {
  it('should build the project successfully', async () => {
    try {
      const { stdout } = await runCommand('npm run build')
      console.log(stdout)
      expect(true).toBe(true) // If no error occurs, the test passes
    } catch (error) {
      if (error instanceof Error) {
        console.error(error.message)
      } else {
        console.error(error)
      }

      expect(true).toBe(false) // Fail the test if an error occurs
    }
  })
})

Build test dry run

Now that we have a build test configured, it's time to give it a dry-run and make sure that it works.

npx vitest run

Depending on the size and complexity of your application, this could take a few moments, so I recommend using the Vitest timeout arguments to prevent a timeout:

npx vitest run --trestTimeout=0

While this executes, you get a handy summary in your terminal that informs you how many tests have passed or failed, when the process began and the duration it has taken so far.

Git Hook automation with Husky

First, let's install Husky as a local dependency:

npm install --save-dev husky

Now we can run the Husky initialisation script which sets up an example pre-commit script which we don't need, but it does provide a useful reference.

*Note that until it is deleted, the pre-commit script will run before each commit.

npx husky init

Now we have a build test working, we can utilise Husky to run our build test anytime code is pushed. This is intended to be a basic example and only scratches the surface of what Husky can do.

#!/bin/sh

echo "Running tests before pushing..."
echo "Check to makesure that there are no local file changes since last commit that could cause a build to differ locally"
git diff HEAD --quiet && npx vitest run --testTimeout=0

# Check if the tests passed
if [ $? -ne 0 ]; then
  echo "Tests failed. Push aborted."
  exit 1
fi

echo "Tests passed. Proceeding with push."
exit 0

Let's break this down into what is happening:

  • We add a shebang, so the file is interpreted as a shell script
  • We output a couple of messages that appear in the terminal when pushing
  • The command git diff HEAD --quiet is used to check if there are any changes in your working directory or staging area compared to the latest commit (HEAD). This is crucial as you don't want your build to fail due to uncommitted code breaking your build
  • We then run our test command npx vitest run --testTimeout=0
  • We check if the exit status of the command is not equal to 0 and exit if there is an error. This prevents a push
  • If everything is successful, the script exists and the push completes

Summary

In this article, we covered how to configure and setup Vitest for running tests in our project. This can be expanded upon to begin building much more resilient products with granular tests for stability, consistency and error catching during development.

We also learned how we can use Husky to run scripts at different points during the git lifecycle and specifically, how to run builds automatically before anything is pushed to Git preventing build errors on remote hosts such as Vercel. This example is very basic but could be expanded upon to limit the Husky pre-push hook to only execute on certain branches.