Different types of tests answer different questions. A unit test tells you whether a function behaves correctly in isolation. A load test tells you whether the server survives a traffic spike. Neither answers the other's question. Most applications need several types working together.
This post covers the ten most common test types, what each one is good for, and what a basic example looks like in JavaScript or Node.js.
1. Unit Testing
Definition
Unit tests verify a single function or method in isolation. The goal is to confirm that one piece of logic behaves correctly for a given input, without involving databases, external services, or other modules.
Why Unit Tests Matter
They're fast, so you can run thousands of them in seconds. When one fails, the failure is localized: you know exactly which function is broken and under what input. They're also the easiest tests to write and maintain because they require no infrastructure.
Example in Node.js
Using Jest, a popular testing framework for JavaScript/TypeScript:
// mathOperations.js
function add(a, b) {
return a + b;
}
module.exports = { add };
// mathOperations.test.js
const { add } = require('./mathOperations');
test('adds two numbers correctly', () => {
expect(add(2, 3)).toBe(5);
});
The function add is tested in isolation. You'd add more tests for edge cases: negative numbers, floating-point inputs, and inputs that aren't numbers at all.
2. Integration Testing
Definition
Integration tests check how separate modules interact. They go beyond individual functions to verify the data flow between components: a route handler calling a database model, an API client talking to an external service, or two internal modules exchanging data.
Why Integration Tests Matter
Individual units can all behave correctly but still fail when combined, because the interface between them is wrong. Integration tests catch those mismatches before they reach production.
Example in Node.js
Consider a simple Express.js application with a database call:
// userController.js
const express = require('express');
const router = express.Router();
const UserModel = require('./UserModel');
router.get('/users/:id', async (req, res) => {
const user = await UserModel.findById(req.params.id);
if (!user) return res.status(404).send('User not found');
res.json(user);
});
module.exports = router;
// integration.test.js
const request = require('supertest');
const express = require('express');
const userController = require('./userController');
const app = express();
app.use('/', userController);
describe('GET /users/:id', () => {
it('should return a user when valid id is provided', async () => {
// Mock database operations or use an in-memory DB
const response = await request(app).get('/users/123');
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('id', '123');
});
});
This test confirms that the route and the data model work correctly together. It's not a full end-to-end test, but it validates more than a unit test would.
3. Functional Testing
Definition
Functional tests verify that the application does what it's supposed to do from the user's perspective, checking behavior against specified requirements. The focus is on what the software does, not how it does it internally.
Why Functional Tests Matter
A function can be internally correct (all unit tests pass) and still not satisfy the requirement. Functional tests tie the implementation back to the business or user need, catching gaps between specification and behavior.
Example in Node.js
A login workflow tested at the HTTP level:
// login.test.js
const request = require('supertest');
const app = require('./app'); // Your Express.js app with auth routes
describe('User Login Functionality', () => {
it('should return a token if user credentials are valid', async () => {
const response = await request(app)
.post('/auth/login')
.send({ username: 'testUser', password: 'secret123' });
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('token');
});
it('should return 401 if credentials are invalid', async () => {
const response = await request(app)
.post('/auth/login')
.send({ username: 'testUser', password: 'wrongPwd' });
expect(response.status).toBe(401);
});
});
These tests confirm the login feature works for valid credentials and rejects invalid ones, matching what the requirements say the feature should do.
4. End-to-End (E2E) Testing
Definition
End-to-end tests simulate real user interactions through a browser, exercising the full stack from the UI to the database and back. They validate that everything works together in conditions close to production.
Why E2E Tests Matter
Changes to the backend can break frontend flows in ways that no unit or integration test catches. E2E tests are the safety net for those cross-layer regressions. They're slower and more expensive to maintain, so they work best on critical user paths.
Example in Node.js
Using Cypress:
// cypress/e2e/login.spec.js
describe('Login Flow E2E', () => {
it('logs in a user with valid credentials', () => {
cy.visit('http://localhost:3000/login');
cy.get('#username').type('testUser');
cy.get('#password').type('correctPassword');
cy.get('button[type="submit"]').click();
cy.url().should('include', '/dashboard');
cy.contains('Welcome, testUser');
});
});
This test drives a real browser through the login flow and confirms the user lands on the dashboard with the correct welcome message.
5. Performance Testing
Definition
Performance tests measure how the application behaves under load: response times, throughput, and resource usage at various traffic levels.
Why Performance Tests Matter
An application that works perfectly with ten concurrent users can fall apart with a thousand. Performance tests find that breaking point before your users do.
Example in Node.js
Using Artillery to test an API endpoint:
// sampleLoadTest.yml (Artillery syntax)
config:
target: "http://localhost:3000"
phases:
- duration: 30
arrivalRate: 10
scenarios:
- flow:
- get:
url: "/users"
This configuration runs 10 requests per second against the /users endpoint for 30 seconds. The resulting metrics show response time percentiles, error rates, and throughput.
6. Security Testing
Definition
Security testing looks for vulnerabilities: unprotected endpoints, SQL injection, XSS, CSRF, and known-vulnerable dependencies.
Why Security Tests Matter
A single exploited vulnerability can expose user data, take down the service, or lead to regulatory fines. Security testing makes vulnerabilities explicit so they can be fixed before attackers find them.
Example in Node.js
# Check for known vulnerabilities in the project's dependencies
npm audit
npm audit scans your dependency tree against a database of known CVEs and reports vulnerabilities by severity. For more thorough testing, tools like OWASP ZAP can automate active scanning of running applications.
7. Acceptance Testing
Definition
Acceptance tests verify that the software meets the requirements agreed upon with stakeholders or clients. They're typically written in human-readable language and confirm that the implementation matches the intended use case.
Why Acceptance Tests Matter
Technical correctness and stakeholder acceptance are not the same thing. A feature can be implemented exactly as spec'd and still miss what was actually needed. Acceptance tests create a shared definition of "done" between developers and the people requesting the feature.
Example in Node.js
Using Cucumber with Gherkin syntax:
# login.feature
Feature: User Login
In order to access my account
As a registered user
I want to be able to log in using valid credentials
Scenario: Successful login
Given I am on the login page
When I enter valid credentials
Then I should be redirected to the dashboard
And I should see a welcome message
Each step maps to a JavaScript step definition that executes the corresponding action. The scenario is readable by non-technical stakeholders and executable by the test runner.
8. Load and Stress Testing
Definition
Load testing checks the system at expected peak traffic. Stress testing pushes beyond that to find where it breaks and how it recovers.
Why Load and Stress Tests Matter
Knowing that your system handles 500 concurrent users is useful. Knowing what happens at 2000, and whether it recovers gracefully when load drops back down, is more useful for capacity planning and incident preparation.
Example in Node.js
A stress test scenario with k6:
// stressTest.js
import http from 'k6/http';
import { sleep } from 'k6';
export let options = {
stages: [
{ duration: '2m', target: 100 }, // ramp up from 0 to 100 users over 2 minutes
{ duration: '5m', target: 100 }, // stay at 100 users for 5 minutes
{ duration: '2m', target: 200 }, // ramp up to 200 users
{ duration: '5m', target: 200 }, // stay at 200 users for 5 minutes
{ duration: '1m', target: 0 }, // ramp down
],
};
export default function() {
http.get('http://localhost:3000/api/resource');
sleep(1);
}
This script ramps up to 200 concurrent users, holds that load, then ramps back down. The metrics at each stage show how performance degrades under sustained load.
9. Regression Testing
Definition
Regression tests re-run existing tests after code changes to confirm that previously working functionality still works.
Why Regression Tests Matter
Every code change is a potential regression. Refactoring, dependency updates, and new features all carry the risk of breaking something that was working before. Running the full test suite on every commit makes that risk explicit.
Example in Node.js
A regression suite is just the existing unit, integration, and E2E tests run together:
// All test files combined under a "regression" script
{
"scripts": {
"test:regression": "jest --config=jest.config.js --runInBand"
}
}
One command re-runs everything. Any failure is a regression that needs investigation before merging.
10. Exploratory Testing
Definition
Exploratory testing is unscripted manual testing where a developer or tester actively explores the application to find unexpected behavior, usability issues, or edge cases that automated tests didn't anticipate.
Why Exploratory Tests Matter
Automated tests only find bugs that someone thought to test for. A tester actively probing the system with curiosity and domain knowledge finds a different class of issues: strange interaction flows, confusing error states, and behaviors that are technically correct but practically wrong.
Example in Practice
A session of exploratory testing might look like: logging in as users with different permission levels, canceling operations midway through, refreshing the page at critical steps, submitting forms with unusual input. None of this is scripted. The tester records what they find for triage and follow-up.
Exploratory testing works best as a complement to automation, not a replacement for it. Automate the known paths and explore for the unknown ones.
Building a Comprehensive Testing Pipeline
Putting these together into a working pipeline requires a few decisions. Integrate tests into CI so every commit triggers a run. Failing tests block merges. Use descriptive test names so failures are self-explanatory without digging into the code. Keep test files in the same repository as the code they test. Track coverage with Istanbul or a similar tool to identify untested paths, especially in service-layer code.
The goal is a suite where each type of test covers the failure modes the others miss: unit tests for logic bugs, integration tests for interface mismatches, E2E tests for cross-layer regressions, and performance and security tests for operational risks. Each layer is fast where it can be, and thorough where speed doesn't matter as much.