Back to Testing guides

A Beginner's Guide to Unit Testing with Uvu

Stanley Ulili
Updated on March 21, 2025

Uvu is a fast, minimalist test runner built for JavaScript and TypeScript projects. It focuses on simplicity, speed, and low overhead.

With Uvu, you get near-instant test execution, a clean and intuitive API, and full support for ES Modules—making it a strong fit for modern workflows that value performance and minimal tooling.

In this article, you'll explore Uvu's core features and learn how to write and execute tests efficiently.

Prerequisites

Before proceeding with this tutorial, ensure you have Node.js installed, preferably version 22.x or newer.

Additionally, this guide assumes familiarity with JavaScript and a basic understanding of testing concepts.

Step 1 — Setting up the directory

In this section, you'll create a project directory and set up Uvu to test your JavaScript code.

To begin, create a new directory and navigate into it:

 
mkdir uvu-demo && cd uvu-demo

Then, initialize the directory as an npm project:

 
npm init -y

This command creates a package.json file, which stores metadata and dependencies for your project.

Next, enable ES Modules by adding the following to your package.json file:

 
npm pkg set type="module"

Now, install Uvu as a development dependency:

 
npm install -D uvu

After installing Uvu, set up the test script in your package.json using the following command:

 
npm pkg set scripts.test="uvu"

You're now ready to create a simple ES Module function and test it using Uvu. Here's the function:

math.js
export function add(a, b) {
  return a + b;
}

The add() function takes two numbers and returns their sum. Save this file in the root directory of your project.

Next, you will write your first test using Uvu.

Step 2 — Writing your first test

Unit tests help verify that individual functions behave as expected under different conditions. Instead of manually checking outputs, you'll automate this process using Uvu's simple but powerful API.

Start by creating a tests directory in your project root:

 
mkdir tests

Next, create a file named math.test.js inside the tests directory and add the following test:

tests/math.test.js
import { test } from 'uvu';
import * as assert from 'uvu/assert';
import { add } from '../math.js';

test('add() should return correct sum', () => {
  assert.is(add(1, 2), 3);
});

test.run();

This setup is clean and minimal. You import the test function from Uvu and use uvu/assert for your assertions. Each test is defined with a description and a function that performs the actual check.

In this case, assert.is() verifies that add(1, 2) returns 3. The final test.run() call tells uvu to execute all the tests defined in the file.

Now that the test is ready, you're set to run it in the next step.

Step 3 — Running your tests

With your test file set up, you can run it using Uvu.

To execute all tests, run:

 
npm test

Since the test script in package.json is set to uvu, this command will find and run all test files:

Output
> uvu-demo@1.0.0 test
> uvu

tests/math.test.js
•   (1 / 1)

  Total:     1
  Passed:    1
  Skipped:   0
  Duration:  0.47ms

The output confirms that uvu successfully located and ran the math.test.js file, and the test passed as expected.

Now, expand the test coverage by adding more cases to check different input scenarios:

tests/math.test.js
import { test } from 'uvu';
import * as assert from 'uvu/assert';
import { add } from '../math.js';

test('add() should return correct sum', () => {
  assert.is(add(1, 2), 3);
});

test('add() should return correct sum with negative numbers', () => {
assert.is(add(-1, 2), 1);
assert.is(add(-1, -2), -3);
});
test('add() should return correct sum with decimals', () => {
assert.is(add(0.1, 0.2), 0.3);
});
test.run();

Rerunning the tests will now execute all test cases, and you'll see color-coded output that highlights which tests passed or failed:

Screenshot of the tests output with one failing test

Oops! One of the tests failed. Specifically, the third test didn't pass due to JavaScript’s floating-point precision issues. This is a common quirk when dealing with decimal values.

Let’s fix it by using a more tolerant comparison method. Instead of assert.is(), use assert.ok() with a small delta to check for approximate equality. Here's how:

tests/math.test.js
import { test } from 'uvu';
import * as assert from 'uvu/assert';
import { add } from '../math.js';
...
test('add() should return correct sum with decimals', () => {
  // Use approximately equal for floating point comparisons
assert.ok(Math.abs(add(0.1, 0.2) - 0.3) < Number.EPSILON);
}); test.run();

Now the tests should pass:

Screenshot of all the tests passing

With your basic test cases working, it’s a good time to look more closely at the tools Uvu gives you for writing effective tests.

Step 4 — Understanding Uvu assertions

Uvu provides a variety of assertion functions through its uvu/assert module. These assertions help you test different aspects of your code with clear, expressive syntax.

Let's create a new test file to explore different assertion types that uvu offers:

tests/assertions.test.js
import { test } from 'uvu';
import * as assert from 'uvu/assert';

test('equality assertions', () => {
  // Basic equality
  assert.is(1, 1);          // Strict equality (===)
  assert.equal({a: 1}, {a: 1}); // Deep equality (useful for objects)

  // Negative assertions
  assert.not.equal(1, 2);   // Values should not be equal
  assert.type([], 'object'); // Type checking

  // Truthiness
  assert.ok(true);          // Checks if value is truthy
});

The assert.is() function checks for strict equality using ===, which makes it ideal for comparing numbers, strings, booleans, and other primitive values. When working with objects or arrays, use assert.equal() instead—it performs a deep comparison to ensure all properties and values match.

To verify that two values are not strictly equal, assert.not() comes in handy. And when you need to confirm that a value is of a specific type, assert.type() makes that check simple and explicit.

Running the complete test file shows all assertions working together:

Output
> uvu-demo@1.0.0 test
> uvu

tests/assertions.test.js
tests/math.test.js
• • • •   (4 / 4)

  Total:     4
  Passed:    4
  Skipped:   0
  Duration:  0.69ms

Testing how your code handles errors is just as important as testing expected behavior. The snippet below demonstrates how to validate error handling in Uvu:

 
test('error assertions', () => {
  // Testing for thrown errors
  assert.throws(() => {
    throw new Error('boom');
  }, /boom/);

  // Testing for not throwing
  assert.not.throws(() => {
    // This function doesn't throw
    return 1 + 1;
  });
});

Here, assert.throws() checks that a function throws an error and allows you to match the error message using a regular expression. On the other hand, assert.not.throws() confirms that a function runs without throwing, helping you validate that code behaves safely under normal conditions.

Uvu also includes specialized assertions for more complex validations:

 
test('comparison assertions', () => {
  assert.instance(new Date(), Date); // Instance checking
  assert.match('hello world', /world/); // Regex matching
  assert.snapshot({foo: 1}, '{"foo":1}'); // Snapshot testing
});

The assert.instance() function checks if a value is an instance of a specific class. assert.match() validates strings against regular expressions, and assert.snapshot() enables comparing objects against serialized versions, which helps detect changes in complex data structures.

This comprehensive set of assertions makes it easy to express your test expectations clearly and concisely, adapting to different testing scenarios.

Step 5 — Filtering tests

As your test suite grows, it becomes increasingly important to run only relevant tests. Uvu provides several ways to filter and run specific tests, making your development workflow more efficient.

Skip and only

Uvu makes it easy to focus on specific tests during development using the .only modifier or temporarily skip tests with .skip. This is useful when debugging or working on a single feature.

Here's how you can update your math test file to demonstrate both:

tests/math.test.js
import { test } from 'uvu';
import * as assert from 'uvu/assert';
import { add } from '../math.js';

test('add() should return correct sum', () => {
  assert.is(add(1, 2), 3);
});

// This test will be skipped
test.skip('add() should return correct sum with negative numbers', () => {
assert.is(add(-1, 2), 1); assert.is(add(-1, -2), -3); }); ...

Running these tests will show that the second test is skipped:

Output
> uvu-demo@1.0.0 test
> uvu

tests/assertions.test.js
tests/math.test.js
• • •   (3 / 3)

  Total:     3
  Passed:    3
  Skipped:   1
  Duration:  0.64ms

Skipped tests are not shown in the line of dots but are still counted in the final summary. In this case, one test was skipped using .skip, and Uvu reports that clearly under Skipped: 1.

This is helpful when you want to temporarily disable a test without deleting it—useful for debugging incomplete features or known issues you plan to revisit.

Similarly, if you want to focus on a specific test, you can use .only:

tests/math.test.js
import { test } from 'uvu';
import * as assert from 'uvu/assert';
import { add } from '../math.js';

// Only this test will run
test.only('add() should return correct sum', () => {
assert.is(add(1, 2), 3); });
test('add() should return correct sum with negative numbers', () => {
assert.is(add(-1, 2), 1); assert.is(add(-1, -2), -3); }); test('add() should return correct sum with decimals', () => { assert.ok(Math.abs(add(0.1, 0.2) - 0.3) < Number.EPSILON); }); test.run();

When you run this, only the test marked with .only will execute:

Output
tests/assertions.test.js
tests/math.test.js
•   (1 / 1)

  Total:     1
  Passed:    1
  Skipped:   3
  Duration:  0.59ms

In this output, you can see that Uvu focuses entirely on the .only-marked test. Regardless of how many exist, all other tests are treated as skipped and excluded from the active test run. This makes .only especially useful when you're actively working on a single test case or debugging a failing scenario without the distraction of unrelated tests.

Command-line filtering

Another powerful way to filter tests is through command-line options without modifying your test code. Let's add another test file to demonstrate this capability:

tests/string.test.js
import { test } from 'uvu';
import * as assert from 'uvu/assert';

function capitalize(str) {
  return str.charAt(0).toUpperCase() + str.slice(1);
}

test('capitalize() should uppercase first letter', () => {
  assert.is(capitalize('hello'), 'Hello');
});

test('capitalize() should handle empty strings', () => {
  assert.is(capitalize(''), '');
});

test.run();

Now, if you want to run only tests related to the string operations, you can use the -p (pattern) flag:

 
npm test -- -p string

This will run only tests with "string" in their file path:

Output
tests/assertions.test.js
tests/math.test.js
•   (1 / 1)

tests/string.test.js
• •   (2 / 2)

  Total:     3
  Passed:    3
  Skipped:   3
  Duration:  0.54ms

Similarly, you can run tests from a specific directory or with specific naming patterns:

 
npx uvu tests tests/math.test.js

Or you can use multiple patterns to include several specific test files:

 
npm test -- -p math -p assertions

These filtering capabilities become increasingly valuable as your test suite grows. They help maintain a fast feedback loop during development by allowing you to focus on relevant tests and minimize wait times, especially in larger projects where the full test suite might take significant time to run.

Step 6 — Using hooks for setup and teardown

As your tests grow more advanced, you'll likely need to run setup tasks before tests execute and handle cleanup afterward. Uvu supports this through a simple set of hooks that help you manage state and shared resources cleanly.

To demonstrate, create a basic file reader example:

fileReader.js
import { promises as fs } from 'fs';

export async function readFileContent(filePath) {
  try {
    const content = await fs.readFile(filePath, 'utf-8');
    return content.trim();
  } catch (error) {
    throw new Error(`Failed to read file: ${error.message}`);
  }
}

Now, write tests that use hooks to set up and clean up the test environment:

tests/hooks.test.js
// tests/hooks.test.js
import { test } from 'uvu';
import * as assert from 'uvu/assert';
import { readFileContent } from '../fileReader.js';
import { promises as fs } from 'fs';

// Setup - runs once before any tests
test.before(async () => {
  console.log('Creating test file...');
  await fs.writeFile('test.txt', 'Hello, world!');
});

// Cleanup - runs once after all tests
test.after(async () => {
  console.log('Removing test file...');
  try {
    await fs.unlink('test.txt');
  } catch (e) {
    // File might not exist if test failed, ignore error
  }
});

test('readFileContent() should read file content', async () => {
  const content = await readFileContent('test.txt');
  assert.is(content, 'Hello, world!');
});

test('readFileContent() should throw when file does not exist', async () => {
  // For async functions, we need to use try/catch to test for errors
  try {
    await readFileContent('non-existent.txt');
    assert.unreachable('Should have thrown an error');
  } catch (error) {
    assert.match(error.message, /Failed to read file/);
  }
});

test.run();

In this example, test.before() is used to create a file before any tests run. This ensures the environment is ready and consistent for each test that needs it. After all tests complete, test.after() removes the file to clean up and avoid side effects.

Using Uvu's hooks like this isolates your tests and avoids polluting the file system or leaving behind artifacts—especially important when working with real resources like files or databases.

Running these tests produces the following output:

Output
tests/assertions.test.js
tests/hooks.test.js
Creating test file...
• • • Removing test file...
  (3 / 3)

tests/math.test.js
•   (1 / 1)

tests/string.test.js
• •   (2 / 2)

  Total:     6
  Passed:    6
  Skipped:   2
  Duration:  2.07ms

The hook lifecycle is visible in the console output - first, the setup message, then the test execution (indicated by the dots), and finally, the cleanup message.

Uvu provides four types of hooks for fine-grained control over your test environment:

  • test.before() - Runs once before any tests execute
  • test.before.each() - Runs before each individual test
  • test.after.each() - Runs after each individual test
  • test.after() - Runs once after all tests complete

Here’s a basic example of how to use these hooks:

 
// Runs once before all tests
test.before(() => {
  // global setup
});

// Runs before each test
test.before.each(() => {
  // setup before each test
});

// Runs after each test
test.after.each(() => {
  // cleanup after each test
});

// Runs once after all tests
test.after(() => {
  // global teardown
});

test('first test', () => {
  assert.ok(true);
});

test('second test', () => {
  assert.is(2 + 2, 4);
});

test.run();

This structure is useful when you need to initialize shared state, prepare test data, or perform cleanup—without repeating code inside each test. The hooks make your tests cleaner and more maintainable, especially as your suite grows.

Now that you can control setup and teardown using hooks, you can start mocking dependencies.

Step 7 — Mocking dependencies

When you're testing parts of your code that interact with things like the file system, the internet, or a database, it's a good idea to keep those interactions separate. Mocking helps with that—it lets you fake those parts so your tests stay quick, reliable, and focused on just the code you're testing.

Uvu doesn’t come with built-in tools for mocking, but it works well with libraries like Sinon, which lets you create things like spies, stubs, and mocks.

To get started, install Sinon as a dev dependency:

 
npm install -D sinon

Since we’re using ES Modules, dependency injection is the easiest and cleanest way to mock something. Here’s a test-friendly version of our fileReader.js file:

fileReader.js
import { promises as fs } from 'fs';

export async function readFileContent(filePath, fileSystem = fs) {
  try {
    const content = await fileSystem.readFile(filePath, 'utf-8');
    return content.trim();
  } catch (error) {
    throw new Error(`Failed to read file: ${error.message}`);
  }
}

The key change here is that the filesystem dependency is now injectable through an optional parameter. By default, it uses the real fs module, but in tests, you can pass in your own mock version.

Now you can write tests that take advantage of this injectable dependency:

tests/mock.test.js
import { test } from 'uvu';
import * as assert from 'uvu/assert';
import { readFileContent } from '../fileReader.js';

test('should return file content on successful read', async () => {
  // Mock implementation that always returns a fixed string
  const mockFs = {
    readFile: async (path, encoding) => 'Mocked content'
  };

  // Use our mock filesystem
  const content = await readFileContent('test.txt', mockFs);

  // Verify the result matches what our mock returns
  assert.is(content, 'Mocked content');
});

test('should handle file not found error', async () => {
  // Mock implementation that always throws
  const mockFs = {
    readFile: async (path, encoding) => {
      throw new Error('File not found');
    }
  };

  // Test with our error-throwing mock
  try {
    await readFileContent('non-existent.txt', mockFs);
    assert.unreachable('Should have thrown an error');
  } catch (error) {
    assert.match(error.message, /Failed to read file: File not found/);
  }
});

test.run();

In this test file, you're passing in mock versions of the fs.readFile method to simulate different scenarios.

The first test uses a mock that always returns a fixed string. This lets you check that readFileContent returns the expected result without reading an actual file.

The second test uses a mock that always throws an error. This helps you ensure the function correctly handles read failures by throwing a meaningful error message.

Each test uses its mock, so they're self-contained and don't rely on the real file system. That keeps the tests fast, reliable, and easy to reason about.

When you run these tests, you’ll see output like this:

Output
tests/assertions.test.js
tests/hooks.test.js
Creating test file...
• • • Removing test file...
  (3 / 3)

tests/math.test.js
•   (1 / 1)

tests/mock.test.js
• •   (2 / 2)

tests/string.test.js
• •   (2 / 2)

  Total:     8
  Passed:    8
  Skipped:   2
  Duration:  2.52ms

As you can see, the test run successfully.

Adding spy functionality

Sometimes, you want to make sure a function returned the correct result and was called with the correct arguments. You can add some basic "spy" behavior to your mocks.

Here's an example in a new test file called spy.test.js:

tests/spy.test.js
import { test } from 'uvu';
import * as assert from 'uvu/assert';
import { readFileContent } from '../fileReader.js';

test('should call readFile with correct parameters', async () => {
  // Track calls to our mock
  const calls = [];

  // Create a mock filesystem with spy capability
  const mockFs = {
    readFile: async (path, encoding) => {
      // Record the call parameters
      calls.push({ path, encoding });
      return 'Spy content';
    }
  };

  // Call the function we're testing
  await readFileContent('config.json', mockFs);

  // Verify the mock was called correctly
  assert.is(calls.length, 1);
  assert.is(calls[0].path, 'config.json');
  assert.is(calls[0].encoding, 'utf-8');
});

test.run();

This simple spy pattern lets you verify exactly how your code calls its dependencies.

When you run this test using npm test, it will verify that your function is correctly passing the expected parameters to its dependencies.

Output
tests/spy.test.js
•   (1 / 1)

Using simple mocks and spy functionality makes it easier to test not just the return values but also the behavior of your code. Checking function calls and arguments gives you more confidence that everything works as expected. These tests stay fast, clean, and easy to maintain since they don’t depend on real files or external systems.

Final thoughts

Uvu offers a fast, lightweight testing experience with a clean API and native support for modern JavaScript features like ES Modules. It's a solid choice for small to medium projects where simplicity and speed matter most.

However, if you’re working on larger applications or want built-in support for mocking, snapshot testing, and a more feature-rich ecosystem, consider switching to Vitest. Vitest is a test-runner–compatible with Vite, offers a Jest-like API, and includes powerful tools like built-in mocking, coverage reports, and fast watch mode.

Author's avatar
Article by
Stanley Ulili
Stanley Ulili is a technical educator at Better Stack based in Malawi. He specializes in backend development and has freelanced for platforms like DigitalOcean, LogRocket, and AppSignal. Stanley is passionate about making complex topics accessible to developers.
Got an article suggestion? Let us know
Licensed under CC-BY-NC-SA

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

Make your mark

Join the writer's program

Are you a developer and love writing and sharing your knowledge with the world? Join our guest writing program and get paid for writing amazing technical guides. We'll get them to the right readers that will appreciate them.

Write for us
Writer of the month
Marin Bezhanov
Marin is a software engineer and architect with a broad range of experience working...
Build on top of Better Stack

Write a script, app or project on top of Better Stack and share it with the world. Make a public repository and share it with us at our email.

community@betterstack.com

or submit a pull request and help us build better products for everyone.

See the full list of amazing projects on github