Imagine that you have developed a complex function for calculating discounts in an online store. The code works fine, but after a month, a colleague makes small changes to another part of the application — and suddenly your calculations begin to give incorrect results. How can you detect such a problem in time? The answer is simple: unit tests.
What are unit tests
Unit tests are automated checks of small pieces of code, usually individual functions or methods. They work as checkpoints that constantly check that your code behaves exactly as intended.
The basic idea is simple: you write code that calls your function with known input data and checks that the result matches the expected one. If the result is correct, the test passes; if not, the test fails and reports an error.
Why unit tests are needed
Beginner developers often ask themselves: why waste time writing tests if you can just check the code manually? There are several reasons.
The first reason is confidence in refactoring. When you have test coverage, you can safely improve the code, knowing that if something breaks, the tests will immediately report it. It's like a safety net for an acrobat.
The second reason is documentation. Well-written tests show how a function should be used, what input data it accepts, and what it returns. This is live documentation that is always up to date.
The third reason is saving time in the long run. Yes, writing tests takes time now, but it will save hours of debugging in the future. Automated tests run in seconds and check all functionality, while manual testing can take hours.

A simple example
Let's look at a practical example in JavaScript. Let's say we have a function for validating email addresses:
function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}Now let's write a unit test for it using the popular Jest framework:
describe('isValidEmail', () => {
test('must return true for a valid email', () => {
expect(isValidEmail('user@example.com')).toBe(true);
});
test('must return false for email without @', () => {
expect(isValidEmail('userexample.com')).toBe(false);
});
test('must return false for email without domain', () => {
expect(isValidEmail('user@')).toBe(false);
});
test('must return false for an empty string', () => {
expect(isValidEmail('')).toBe(false);
});
});Each test checks a specific scenario. If someone changes the regular expression and accidentally breaks the validation, the tests will immediately detect it.
Principles of writing good tests
A good unit test should be independent. This means that it should not depend on the results of other tests or on the order in which they are performed. Each test creates its own data and checks only one specific thing.
Tests should be fast. If it takes minutes to run all the tests, developers will run them less often. Unit tests should be performed in milliseconds so that they can be run after each code change.
Tests should be clear. When a test fails, you should immediately understand what exactly is broken. Use descriptive test names and clear error messages.
AAA pattern
A popular approach to structuring tests is the AAA pattern: Arrange, Act, Assert.
test('must correctly calculate the 10% discount', () => {
// Arrange — data preparation
const price = 1000;
const discount = 10;
// Act — performing an action
const result = calculateDiscount(price, discount);
// Assert — checking the result
expect(result).toBe(900);
});This structure makes the tests readable and understandable even for those who see the code for the first time.
Boundary case testing
One of the most important tasks of unit tests is to check boundary cases. These are situations that are on the edge of acceptable values: empty arrays, zero values, maximum numbers, and special characters.
describe('calculateAge', () => {
test('must return 0 for a newborn', () => {
const today = new Date();
expect(calculateAge(today)).toBe(0);
});
test('must process a leap year', () => {
const birthDate = new Date('2000-02-29');
expect(calculateAge(birthDate)).toBeGreaterThan(0);
});
test('should throw an error for a date in the future', () => {
const futureDate = new Date('2030-01-01');
expect(() => calculateAge(futureDate)).toThrow();
});
});It is the boundary cases that most often become the source of bugs in production.

Mocks and stubs
Often, the function depends on external services: databases, APIs, and the file system. We do not want to use real external resources for unit tests — it is slow and unreliable. Instead, we use mocks and stubs.
// Function we are testing
async function getUserData(userId) {
const response = await fetch(`/api/users/${userId}`);
return response.json();
}
// Mock test
test('must receive user data', async () => {
// Creating a mock for fetch
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve({ id: 1, name: 'Alexey' })
})
);
const userData = await getUserData(1);
expect(userData.name).toBe('Alexey');
expect(fetch).toHaveBeenCalledWith('/api/users/1');
});Mocks allow you to isolate the code under test and verify that the function interacts correctly with external dependencies.
Code coverage with tests
Code coverage shows what percentage of your code is executed during tests. Most testing tools can generate coverage reports.
However, it is important to understand that 100% coverage does not guarantee the absence of bugs. Coverage shows that the code has been executed, but does not guarantee that it has been tested for all possible scenarios. Strive for reasonable coverage of critical parts of the code.
Testing in different languages
The concept of unit tests is universal, but the tools differ depending on the programming language.
The pytest and unittest frameworks are popular in Python. They provide a simple syntax for writing tests and many built-in utilities for checks.
JavaScript and TypeScript use Jest, Mocha, Jasmine. Jest is especially popular due to its built-in support for mocks and a convenient API.
In Vue.js applications, Vue Test Utils is often used in conjunction with Jest, which allows you to test components in isolation, check their rendering and interaction with the user.
Java traditionally uses JUnit, and PHP uses PHPUnit. Each of these tools is adapted to the peculiarities of its language, but the basic principles remain the same.
TDD: development through testing
Some teams practice TDD (Test-Driven Development) — an approach in which tests are written before the main code is written. The process looks like this: first you write a failing test that describes the desired behavior, then you write the minimum code to make the test pass, and finally, you refactor the code, keeping the tests green.
This approach helps to better think through the architecture and ensures that all code is covered by tests. However, TDD requires discipline and is not always suitable for experimental projects.
When unit tests are not needed
Honestly, not all code needs unit tests. Simple getters and setters, trivial formatting functions, UI components without logic — all this can do without tests or be covered by integration tests.
Focus on testing business logic, complex algorithms, functions with conditional logic, and code that is critical to the operation of the application. This is where the tests will be of the greatest benefit.
Do you want to learn more about testing and other professional development practices?
Appendix Code offers interactive courses in Python, JavaScript and many other technologies. The training is built from simple to complex with practical tasks and real examples.
Join our Telegram channel, where you will find an active community of developers ready to help with any questions, share experiences and support you on the way to learning programming!
