TidyURLs
Go to Tools
BLOG
The TidyURLsJournal

Practical guides, tutorials, and insights about URLs, SEO, web development, and online marketing. Learn, troubleshoot, and get the most of the web.

Back to the Journal
TidyURLs Journal

How to Write JavaScript Unit Tests Without Overcomplicating It

Practical insights, clear explanations, and useful ideas for understanding the web.

October 25, 20267 min read5 topics
How to Write JavaScript Unit Tests Without Overcomplicating It
Explore the details
#JavaScript#Unit Testing#Node.js#Software Testing#Testing Tutorial
October 25, 20267 min read

How to Write JavaScript Unit Tests Without Overcomplicating It

A useful unit test doesn’t need a complex framework, elaborate fixtures, or a test for every line. It should check that a small piece of code behaves as expected, including at its boundaries. This JavaScript unit testing tutorial uses Node.js’s built-in test runner to show how to write a testable function, cover edge cases, run tests, and decide what belongs in a unit test versus an integration or end-to-end test.

Key takeaways

  • Start with one function that has a clear input and output.
  • Test normal behavior, boundary cases, and invalid input that the function is expected to reject.
  • Use Node.js’s built-in node:test and node:assert/strict for a small project that does not need a third-party test framework.
  • Keep unit tests focused; use integration and end-to-end tests to check how larger parts of the application work together.

Start with a small, predictable function

A function is easy to test when its behavior is clear and it does not depend on hidden state such as the current time, a network request, or a database. Consider a helper that returns the initials for a name. We’ll define its behavior as follows:

  • Trim whitespace at the beginning and end.
  • Use the first character of each whitespace-separated word.
  • Return an empty string for an empty or whitespace-only name.
  • Throw a TypeError if the input is not a string.

Save this function as initials.mjs:

export function getInitials(name) {
  if (typeof name !== 'string') {
    throw new TypeError('Name must be a string');
  }

  const words = name.trim().split(/\s+/).filter(Boolean);
  return words.map((word) => word[0].toUpperCase()).join('');
}

The explicit rules matter. Without them, a test may encode an assumption the function was never meant to meet. This example handles ordinary names written with basic Latin characters; it is not a complete solution for every writing system or every Unicode grapheme.

Choose a test runner that fits the project

For a JavaScript project running on Node.js, the built-in test runner is a straightforward starting point. It provides test definitions through node:test, while node:assert/strict provides assertions that fail when an expected condition is not met. No additional test package is needed for this example.

Create initials.test.mjs alongside the function:

import test from 'node:test';
import assert from 'node:assert/strict';
import { getInitials } from './initials.mjs';

test('returns initials for a first and last name', () => {
  assert.equal(getInitials('Ada Lovelace'), 'AL');
});

test('trims the name and handles repeated whitespace', () => {
  assert.equal(getInitials('  ada   lovelace  '), 'AL');
});

test('returns an initial for a single word', () => {
  assert.equal(getInitials('Ada'), 'A');
});

test('returns an empty string for empty or whitespace-only input', () => {
  assert.equal(getInitials(''), '');
  assert.equal(getInitials('   '), '');
});

test('rejects non-string input', () => {
  assert.throws(() => getInitials(null), TypeError);
});

These tests use assert.equal for expected values and assert.throws for invalid input. Each test describes one behavior, so a failure points you toward the rule that needs attention. The examples also cover edge cases: extra spaces, a single word, empty input, and a value of the wrong type.

Run the tests

Use a currently supported Node.js release that includes the built-in test runner. From the project directory, run:

node --test

Node discovers files matching its test-file patterns, including names ending in .test.mjs. You can also pass a test file explicitly:

node --test initials.test.mjs

When tests pass, the runner reports successful tests. When an assertion fails, read the reported test name and the expected-versus-actual values, then fix either the implementation or the test if its expectation does not match the intended behavior.

For a project with a package.json, you can add a script so the command is easy to remember:

{
  "scripts": {
    "test": "node --test"
  }
}

Then run npm test. This script invokes the Node.js test runner; it does not add a separate testing framework.

Unit, integration, and end-to-end tests are different

The labels describe the scope of what a test checks. They are complementary, not competing choices.

Test typeWhat it checksExample
UnitA small function or module in isolationgetInitials('Ada Lovelace') returns 'AL'
IntegrationMultiple parts working together across a boundaryA service saves a user through a database adapter
End-to-end (E2E)A user-facing workflow through the applicationA user submits a form and sees the resulting page

A unit test should not need a real database or browser to check this initials helper. An integration test might check that a larger service uses a data store correctly. An end-to-end test might exercise the complete sign-up journey in a browser. Use the narrowest test type that can meaningfully verify the behavior in question.

Add tests where they reduce uncertainty

A practical starting workflow is:

  1. Write down the behavior. State what valid inputs do and what should happen for important edge cases.
  2. Identify a small unit. Prefer a function or module with a clear responsibility.
  3. Test representative cases. Include a normal case, a boundary or empty case, and invalid input if rejection is part of the contract.
  4. Run the tests. Use the test command after changing the implementation.
  5. Add a test when fixing a bug. A focused regression test helps keep that specific failure from returning unnoticed.
  6. Refactor when useful. Tests should check observable behavior, not rely on unnecessary implementation details.

A test suite does not have to mirror every line of source code. Aim to cover meaningful behavior and decisions. If the function has no branching and its behavior is already well covered by a broader test, another near-duplicate test may add little value.

Common mistakes to avoid

Testing implementation details

Tests that depend on private variable names or the exact sequence of internal steps can fail after a harmless refactor. Prefer checking the public result or documented error behavior.

Writing tests with unclear expectations

An assertion is only useful if the expected result reflects an agreed rule. Define behavior first, especially for empty values, whitespace, malformed input, and boundary conditions.

Putting unrelated checks in one test

A test that covers many unrelated behaviors can be difficult to diagnose when it fails. Split tests by behavior, while avoiding a separate test for every trivial line.

Assuming passing tests prove the application is correct

Tests only check the scenarios they cover. A passing unit test for a helper does not confirm that a database connection, browser workflow, or production environment works. Combine test types where the project’s risks justify them.

Making every unit test depend on external services

Network calls, clocks, and databases can make a small test slow or unreliable. Keep a unit focused on its local logic; test external boundaries at an appropriate integration level. When dependencies must be controlled, use small interfaces or test doubles deliberately rather than adding complex mocking for its own sake.

Keep the setup proportional

Node’s built-in runner is enough for small projects and many libraries. A team may choose a maintained third-party framework when it needs features such as a particular browser environment, a specific mocking workflow, or integration with an existing toolchain. That choice should solve a real project need; it is not a prerequisite for writing useful tests.

The example uses ES modules with .mjs files so it runs without changing package.json. In an existing project, follow its module format and test conventions rather than mixing CommonJS and ES modules without a reason. Check the official Node.js test runner documentation for the runner’s current API and supported options.

Conclusion

Effective JavaScript unit testing can begin with a small function, a few explicit behavior rules, and clear assertions. Cover the typical path and meaningful edge cases, run tests with a command your team can repeat, and use integration or end-to-end tests when the behavior crosses application boundaries. Keep the setup as simple as the project allows, and add complexity only when it addresses a concrete need.

References