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.
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.
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:
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");
}| Phase | What happens | Example |
|---|---|---|
| Arrange | Create the input data and any dependencies | const input = "hello" |
| Act | Call the function being tested | const result = capitalize(input) |
| Assert | Verify the result matches expectations | result === "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 these | Skip these |
|---|---|
| Functions with conditional branches | Simple getters and setters |
| Data transformation and calculation | Functions that only call another function |
| Validation and parsing logic | Trivial one-liners with no branching |
| Functions with edge cases | Framework boilerplate |
For a given function, write tests for three categories: the happy path, edge cases, and error cases.
function isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
isValidEmail("user@example.com"); // true, the happy pathEdge 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.
isValidEmail(""); // false, empty string
isValidEmail("a@b.c"); // true, minimal valid address
isValidEmail("user@localhost"); // true, no top-level domainError 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:
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:
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"); // GoodA 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.
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:
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.
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:
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.
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:
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.
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
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.
Frequently Asked Questions
What is the difference between unit testing and integration testing?
How many tests should I write for one function?
Should I test private functions?
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.
More in this topic
Using Decorators for Logging in JS Architecture
Learn how JavaScript decorators wrap class methods to add logging without touching the original code, including setup with Babel and how execution order works.
JavaScript While Loop Explained: A Complete Guide
A while loop repeats code as long as a condition stays true. Learn the syntax, see practical examples, and understand when a while loop is the right choice over a for loop.
JavaScript Loops Tutorial: for, while, do while
Loops let JavaScript repeat code without writing it twice. Learn the three core loop types, what each one does, and when to choose one over another.