Unit Testing JavaScript Functions: Complete Guide

Learn what unit testing is, why it matters, and how to write your first tests. Covers the arrange-act-assert pattern, what makes a good test, and what to test versus what to skip.

7 min read

A unit test is a small piece of code that verifies a single function behaves correctly. You give the function an input, run it, and check that the output matches what you expect. If the output is wrong, the test fails and tells you exactly what broke.

Unit tests are the first line of defense against bugs. They run in milliseconds, they run every time you change the code, and they catch mistakes before they reach production.

What a unit test looks like

Every unit test follows the same three-step pattern: arrange the input, act by calling the function, and assert the result.

javascriptjavascript
function capitalize(word) {
  return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
}

A hand-written test without any framework still makes the three steps explicit, using a plain if statement to throw an error whenever the assertion fails instead of relying on a library:

javascriptjavascript
function testCapitalize() {
  const input = "hello";               // Arrange
  const result = capitalize(input);    // Act
 
  if (result !== "Hello") {            // Assert
    throw new Error(`Expected "Hello", got "${result}"`);
  }
  console.log("testCapitalize passed");
}
PhaseWhat happensExample
ArrangeCreate the input data and any dependenciesconst input = "hello"
ActCall the function being testedconst result = capitalize(input)
AssertVerify the result matches expectationsresult === "Hello"

If any phase is missing, the test is unclear. If the phases are mixed together, the test becomes hard to debug when it fails, since you cannot tell at a glance which part represents the expected outcome.

What to test

Not every function needs a unit test. Test functions that contain logic you can get wrong, and skip functions that have nothing to break.

Test theseSkip these
Functions with conditional branchesSimple getters and setters
Data transformation and calculationFunctions that only call another function
Validation and parsing logicTrivial one-liners with no branching
Functions with edge casesFramework boilerplate

For a given function, write tests for three categories: the happy path, edge cases, and error cases.

javascriptjavascript
function isValidEmail(email) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
 
isValidEmail("user@example.com"); // true, the happy path

Edge cases probe the boundaries of what counts as valid: empty input, the smallest valid value, and unusual but still technically valid input that the happy path never exercises.

javascriptjavascript
isValidEmail("");                // false, empty string
isValidEmail("a@b.c");           // true, minimal valid address
isValidEmail("user@localhost");  // true, no top-level domain

Error cases confirm the function rejects input that looks close to valid but is not, which is exactly where regex-based validation most often has quiet gaps:

javascriptjavascript
isValidEmail("not-an-email");      // false, no @ symbol at all
isValidEmail("@missing-user.com"); // false, nothing before the @

Start with the happy path, since it confirms the function works in the most common scenario. Then add edge cases, then error cases. Each individual test should check exactly one scenario.

Writing good test names

A good test name tells you what broke without reading the test body:

javascriptjavascript
test("email test 1");   // Bad, tells you nothing
test("returns false");  // Bad, false for what input?
 
test("returns false when email is missing @ symbol"); // Good
test("returns true for valid email with subdomain");  // Good

A useful pattern is "returns X for Y" or "should Y when Z." When a test fails later, a name built this way tells you exactly what scenario broke without opening the file.

Testing functions with dependencies

Most real functions depend on something else: a database, an API, the filesystem, or another module. Unit tests isolate the function by replacing those dependencies with mock objects.

javascriptjavascript
async function getUserName(apiClient, userId) {
  const user = await apiClient.getUser(userId);
  return user.name;
}

The mock stands in for the real API client and returns a known value instead of making a network call:

javascriptjavascript
async function testGetUserName() {
  const mockApi = {
    getUser: async (id) => ({ name: "Alice", id }),
  };
 
  const name = await getUserName(mockApi, 1);
 
  if (name !== "Alice") {
    throw new Error(`Expected "Alice", got "${name}"`);
  }
}

The test runs instantly with no network, no authentication, and no risk of the real API being down. Because you control exactly what the mock returns, you can also test scenarios like network errors or unexpected data shapes that would be hard to trigger against a real server.

Testing that a function throws

When a function should throw for invalid input, test that it actually throws, and that it throws the right type with the right message.

javascriptjavascript
function requirePositive(n) {
  if (n <= 0) {
    throw new RangeError("Number must be positive");
  }
  return n;
}

The test wraps the call in its own try...catch, since a thrown error would otherwise stop the test function before the assertion runs:

javascriptjavascript
function testRequirePositiveThrows() {
  try {
    requirePositive(-5);
    throw new Error("Test failed: function did not throw");
  } catch (e) {
    if (!(e instanceof RangeError)) {
      throw new Error(`Expected RangeError, got ${e.name}`);
    }
  }
}

Testing error cases is one of the most valuable things unit tests catch. When someone later removes or weakens a validation check, this test fails and the reviewer sees it before merging.

Testing async functions

Async functions return promises. Your test must await the result or return the promise, or the assertion inside it will never actually run.

javascriptjavascript
async function fetchUserName(api, id) {
  const response = await api.get(`/users/${id}`);
  return response.data.name;
}

The test mocks the API the same way as the earlier example, then awaits the call before checking the result:

javascriptjavascript
async function testFetchUserName() {
  const mockApi = {
    get: async (url) => ({ data: { name: "Bob" } }),
  };
 
  const name = await fetchUserName(mockApi, 42);
 
  if (name !== "Bob") {
    throw new Error(`Expected "Bob", got "${name}"`);
  }
}

If you forget the await on fetchUserName, the test function finishes before the promise resolves and the assertion never runs. The test appears to pass, silently, while testing nothing at all.

What not to test

Avoid tests that add maintenance cost without preventing bugs:

  • Trivial code, such as getName() { return this.name; }, has no logic to break.
  • Third-party library internals should stay untested; test your code that uses the library instead.
  • Implementation details, like which private methods get called, should stay out of tests; test the public output.
  • Constants and configuration that never change only create noise if you write tests for them.

When in doubt, ask: if someone changed this code incorrectly, would a test catch it? If the answer is no, you are testing the wrong thing, or the code is too simple to need a test at all.

Deciding what to unit test

Test the functions where mistakes are likely. Skip the functions where mistakes are impossible or already caught by TypeScript.

For putting these concepts into practice with a real testing framework, see the Vitest testing guide. For more on the errors your tests will catch, see the error types guide.

Rune AI

Rune AI

Key Insights

  • A unit test verifies one function's behavior in isolation.
  • The arrange-act-assert pattern: set up data, call the function, check the result.
  • Test the happy path first, then edge cases, then error conditions.
  • A good test name describes the scenario and expected outcome.
  • Mock external dependencies like APIs and databases so tests stay fast and deterministic.
  • Do not test trivial code like getters and setters; focus on logic that can break.
RunePowered by Rune AI

Frequently Asked Questions

What is the difference between unit testing and integration testing?

A unit test verifies one function or module in isolation, mocking any external dependencies. An integration test verifies that multiple units work together correctly, often with real databases, APIs, or file systems.

How many tests should I write for one function?

Test the happy path (normal input), edge cases (empty input, very large input, boundary values), and error cases (invalid input that should throw). Usually 3 to 8 tests per function, depending on complexity.

Should I test private functions?

No. Test private functions indirectly through the public API that calls them. If a private function is complex enough to need its own tests, consider extracting it into its own module with a public interface.

Conclusion

Unit testing is about confidence. Each test you write is a contract: given this input, the function should return this output. When you change the code later, the test either passes, meaning your change is safe, or fails, meaning it broke something. Start with the happy path, add edge cases, and test error conditions. The pattern is always the same: arrange your data, act by calling the function, assert the result.