DataArt's Senior Developer Alexey Klimenko explains why testing matters and how to approach it in practice. This guide covers core concepts, test types, working strategies, best practices, and a risk-based mindset to help teams make testing a natural part of engineering culture.
Why Do We Need Tests?
Testing often gets buried under buzzwords: coverage, reports, pipelines, TDD debates. Strip that away, and the idea is simple. Tests exist to give us confidence.
A clear testing strategy delivers tangible benefits:
- Higher product quality, with fewer production bugs and hidden issues
- Fewer regressions, reducing stress when shipping new features
- Lower long-term costs, since refactoring and fixes become safer and faster
- Reduced business risk from broken core flows
In essence, tests create a safety net. They make growth possible without turning every change into a gamble.
Understanding Test Types:
Instead of memorizing labels, it helps to look at testing from three perspectives:
- By level — what exactly we're testing
- By approach — how we write and run the tests
- By goal — what this particular test is meant to cover
By Level: From Unit to E2E (End-to-End)
Unit Tests
Unit tests validate small, isolated pieces of logic: functions, utilities, methods.
A good unit test is fast, independent of the database/network/timing, and focused on a specific behavior.
Example:
// utils/calcDiscount.js
export function calcDiscount(price, percent) {
if (percent < 0 || percent > 100) {
throw new Error('Invalid percent');
}
return price - (price * percent) / 100;
}
// calcDiscount.test.js (Jest)
import { calcDiscount } from './calcDiscount';
describe('calcDiscount', () => {
it('applies percentage discount', () => {
expect(calcDiscount(100, 10)).toBe(90);
});
it('throws on invalid percent', () => {
expect(() => calcDiscount(100, 150)).toThrow('Invalid percent');
});
});
Component Tests
Component tests focus on UI components in isolation: different props, states, and events.
Example:
// components/Counter.jsx
export const Counter = ({ initial = 0 }) => {
const [value, setValue] = React.useState(initial);
return (
<div>
<span aria-label="value">{value}</span>
<button onClick={() => setValue(value + 1)}>+</button>
</div>
);
}
// Counter.test.jsx (React Testing Library + Jest)
import { render, screen, fireEvent } from '@testing-library/react';
import { Counter } from './Counter';
it('increments value when user clicks plus', () => {
render(<Counter initial={1} />);
const value = screen.getByLabelText('value');
const button = screen.getByText('+');
expect(value).toHaveTextContent('1');
fireEvent.click(button);
expect(value).toHaveTextContent('2');
});
Integration Tests
Integration tests verify how multiple modules of the system work together: controller + validator, component + API(mocked).
Example (a hypothetical service + external client):
// services/userService.js
export async function createUser(userData, { userRepo, emailService }) {
const user = await userRepo.save(userData);
await emailService.sendWelcome(user.email);
return user;
}
// userService.integration.test.js
import { createUser } from './userService';
it('creates user and sends welcome email', async () => {
const savedUsers = [];
const sentEmails = [];
const userRepo = {
save: async (userData) => {
savedUsers.push(userData);
return { id: 1, ...userData };
},
};
const emailService = {
sendWelcome: async (email) => {
sentEmails.push(email);
},
};
const result = await createUser(
{ email: 'test@example.com' },
{ userRepo, emailService }
);
expect(result.id).toBe(1);
expect(savedUsers).toHaveLength(1);
expect(sentEmails).toContain('test@example.com');
});
E2E (End-to-End) Tests
This is the whole user journey through the system: from the UI down to the database and back. E2E tests are more expensive and slower to maintain, but they give us tremendous confidence that real-world scenarios actually work.
Example:
// e2e/checkout.spec.js
import { test, expect } from '@playwright/test';
test('user can buy a product', async ({ page }) => {
await page.goto('https://my-shop.example');
await page.getByText('Fancy Mug').click();
await page.getByRole('button', { name: 'Add to cart' }).click();
await page.getByRole('link', { name: 'Cart' }).click();
await page.getByRole('button', { name: 'Checkout' }).click();
await page.getByLabel('Card number').fill('4242 4242 4242 4242');
await page.getByLabel('Expiry').fill('12/30');
await page.getByLabel('CVC').fill('123');
await page.getByRole('button', { name: 'Pay' }).click();
await expect(page.getByText('Thank you for your purchase')).toBeVisible();
});
By Approach: How Tests Are Created
Manual Vs. Automated
Manual — a tester/developer goes through scenarios by hand.
Automated — scenarios are written as code and run in CI.
Manual testing isn't going anywhere, but as a project grows, having an automated "safety layer" becomes increasingly valuable.
TDD (Test-Driven Development)
TDD follows a simple loop:
- Write a failing test (red)
- Write the minimum amount of code to pass the test (green)
- Remove duplication / clean up the code (refactor)
BDD (Behavior-Driven Development)
BDD focuses on a shared understanding of how the system should behave.
BDD-style tests do not have to be a formal BDD process with lots of meetings and Gherkin files. You can use the approach partially, simply as a convenient way to keep your focus on behavior.
Key ideas:
- We talk in terms of behavior, not implementation details
- We use the Given/When/Then structure
- Scenarios are understandable to developers, QA, analysts, and business people
- Tests become a form of living documentation
# cart.feature
Feature: Shopping cart
Scenario: User can add an item to the cart. Given the user is on the shop page and the cart is empty
When the user adds an item to the cart
Then the cart shows "1 item in cart"
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from '@playwright/test';
Given('the user is on the shop page', async function () {
await this.page.goto('https://my-shop.example');
});
Given('the cart is empty', async function () {
// reset the basket state, e.g. ensure it's empty
});
When('the user adds an item to the cart', async function () {
await this.page.getByText('Add to cart').click();
});
Then('the cart shows {string}', async function (text) {
await expect(this.page.getByText(text)).toBeVisible();
});
Exploratory Testing
It relies on curiosity: what happens if…?
Examples:
- Rapidly switching between tabs to see if the UI breaks
- Clicking a button 10 times a second
- Entering unexpected values
- Killing the network
- Reloading the page in the middle of a request
Exploratory testing often identifies bugs that formal test scenarios miss entirely.
By Goal: What Is Being Validated
Functional Testing
We check what the system does. Common categories include:
- Boundary — test edge cases and limits (e.g., min/max values, “just below/just above” a limit, Input limit is 10 characters → testing 9, 10, 11)
- Regression — ensure existing functionality still works (e.g., new feature added → old flow still works)
- Smoke — quick “does it run at all?” check (e.g., you fixed payment modal → check app loads, login works, basic flows still function)
- Sanity — quick validation of a specific fix or feature (e.g., you fixed the payment modal → check only the payment modal behavior)
Non-Functional Testing
Here, the focus is on how the system behaves:
- Performance — speed, load, response times
- Security — vulnerabilities, permissions, attacks
- Usability — how easy it is to use
- A11y (Accessibility) — screen readers, keyboard navigation, contrast, etc.
- Compatibility — different browsers/devices
- Reliability — stability over long runs, restarts, and network issues
Specialized Test Types
Snapshot Testing
Snapshot tests compare a saved version of the UI/DOM to the current one. They're handy for components.
Example (Jest + React Testing Library):
// Button.test.jsx
import { render } from '@testing-library/react';
import { Button } from './Button';
it('renders primary button', () => {
const { container } = render(<Button variant="primary">Click me</Button>);
expect(container.firstChild).toMatchSnapshot();
});
Visual Regression/Screenshot Testing
Tools like Playwright or Storybook can compare screenshots pixel-by-pixel. For example, you can see if a button moved after a CSS change.
Mutation Testing
Tools like Stryker deliberately “break” your code (e.g., change operators or conditions) and check whether your tests can catch it. The idea is simple: if you can break business logic and the tests are still green, the quality of your tests is low — even if coverage is high.
Why so many classifications if, in the end, we are just checking that everything works? Separations are needed not for the sake of theory, but for the sake of risk, time, and cost management.
Why So Many Test Categories
The goal is not theory. It is risk management.
In real projects:
- Time is limited
- Money is limited
- Risks vary
- There are many changes
Therefore, testing is divided into types to understand:
- What needs to be tested deeply
- What can be tested quickly
- What can be tested superficially
- What must be tested after changes
Reducing Flaky Tests
Flaky tests (sometimes red, sometimes green with no code changes) destroy trust in your test suite.
To reduce instability:
- Use stable test environments
- Control your data (fixtures, factory functions)
- Isolate from unstable external services (mocks/fakes)
- Make tests deterministic (fake timers, control randomness)
FIRST Principles of Good Tests
Strong tests usually follow the FIRST model:
Fast — the test runs quickly
Independent — doesn't depend on other tests
Repeatable — gives the same result in any environment
Self-checking — validates itself (green/red) without manual log inspection
Timely — written at the right time (not a year after the feature was implemented)
Key Testing Realities
⭐ Bugs are inevitable
The goal of testing is not to "prove there are no bugs", but to reduce the risk of serious problems. We test not because someone writes bad code, but because errors are a natural part of software development.
⭐ You can't test everything
The space of possible inputs and scenarios is infinite. So, you have to make choices. We must choose what to test based on importance and risk, rather than attempting to cover everything.
⭐ Adopt a risk-based mindset
Focus your testing effort on areas that:
- Break most often
- Are critical for the business
- Are complex and challenging to understand
- Have tricky integrations
Testing is an investment. We put more effort into areas where failure would be costly.
⭐ The pesticide paradox
If you keep running the same set of tests, they eventually stop finding new bugs, like pests getting used to the same poison. Your test suite needs to be updated and expanded periodically. Tests must be reviewed and improved regularly; otherwise, they become noise and lose their value.
⭐ Quality is a team responsibility
It's not "the QA’s job" or "whoever writes tests". Architecture decisions, deadlines, scope, and attitude to technical debt all affect quality. Everyone (Dev, QA, PM, DevOps) contributes to product quality, so testing decisions and responsibilities must be shared.
Coverage: What Do 80% Actually Mean?
Coverage (line/function coverage) is often turned into a KPI. But it’s important to remember: Coverage ≠ test quality.
You can have 90% coverage and still:
- Never test boundary values
- Fail to catch real bugs
- Ignore important branches in conditions
Use coverage to identify blind spots:
- Untested modules
- Untouched code paths
- Rare scenarios
For many projects, 70–90% is reasonable, but what really matters is what exactly is being tested, not the number itself.
Testing as Part of Engineering Culture
Testing is not a luxury or a "nice-to-have if there's time left". It's part of the engineering discipline.
When the team has a basic understanding of the types of tests available, what value they bring, and how to think about coverage and risk, you can start arguing about details like Jest/Vitest, Cypress/Playwright, and how many E2E tests are needed.
But the foundation is the same: Testing = Engineering discipline.
Treat testing as risk management, not bureaucracy. Teams that adopt this mindset ship faster, break less, and release with confidence.
*The article was initially published on DataArt Team blog.
Top comments (0)