TL;DR
- Test automation runs tests automatically — faster feedback, more coverage, fewer bugs in production
- Start with the test automation pyramid: many unit tests, some integration, few E2E
- First tool choice: Playwright (web), Jest (JS), pytest (Python) — pick based on your stack
- Automate regression tests first — stable features that break during changes
- Avoid automating everything — focus on high-value, repeatable tests
Best for: Developers, QA engineers, anyone wanting to automate repetitive testing Skip if: Testing one-time scripts or prototypes that won’t be maintained Reading time: 20 minutes
Your team releases every two weeks. Manual regression takes three days. By the time testing finishes, developers have moved on to new features. Bugs found during regression mean context switching and delays.
Test automation fixes this. Tests run in minutes instead of days. Developers get feedback while the code is fresh. Regression runs on every commit, not just before releases.
This tutorial teaches test automation from scratch — core concepts, choosing tools, writing your first tests, and building maintainable test suites.
What is Test Automation?
Test automation uses software to execute tests, compare actual results with expected outcomes, and report failures. Instead of a person clicking through an application, a script does it automatically.
Manual testing:
1. Open browser
2. Navigate to login page
3. Enter username "testuser"
4. Enter password "secret123"
5. Click Login button
6. Verify dashboard appears
7. Check that username shows in header
Automated test:
test('user can login', async ({ page }) => {
await page.goto('/login');
await page.fill('#username', 'testuser');
await page.fill('#password', 'secret123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('.header-username')).toHaveText('testuser');
});
Both test the same thing. The automated version runs in seconds, every time code changes, without human involvement.
Why Automate Tests?
Speed: A manual regression suite taking 3 days runs in 30 minutes automated.
Consistency: Automated tests execute the same steps every time. Humans skip steps, misread data, forget edge cases.
Coverage: You can run thousands of tests overnight. Manually, you’d test the happy path and call it done.
Earlier feedback: Tests run on every commit. Bugs caught in development cost 10x less than bugs in production.
Developer confidence: Good test coverage means developers refactor without fear. Change the code, run the tests, know if something broke.
When NOT to automate:
- Exploratory testing — humans find unexpected bugs
- One-time tests — automation setup cost exceeds benefit
- Rapidly changing features — tests break faster than they provide value
- Visual/UX evaluation — humans judge aesthetics better
The Test Automation Pyramid
The pyramid guides how many tests to write at each level.
/\
/ \ E2E Tests (few)
/----\ UI, full workflows
/ \
/--------\ Integration Tests (some)
/ \ API, components together
/------------\
/ \ Unit Tests (many)
/________________\ Functions, classes
Unit Tests (Base)
Test individual functions or classes in isolation.
# calculator.py
def add(a, b):
return a + b
# test_calculator.py
def test_add_positive_numbers():
assert add(2, 3) == 5
def test_add_negative_numbers():
assert add(-1, -1) == -2
Characteristics:
- Fast (milliseconds)
- Isolated (no database, network, file system)
- Many (hundreds to thousands)
- Run on every commit
Integration Tests (Middle)
Test how components work together.
def test_create_user_saves_to_database():
# Uses real database connection
user_service = UserService(db_connection)
user = user_service.create_user("john@example.com")
saved_user = db_connection.query(f"SELECT * FROM users WHERE id={user.id}")
assert saved_user.email == "john@example.com"
Characteristics:
- Slower (seconds)
- Test real interactions
- Medium quantity (dozens to hundreds)
- Run on PR/merge
E2E Tests (Top)
Test complete user workflows through the UI.
test('user can complete purchase', async ({ page }) => {
await page.goto('/products');
await page.click('[data-product="laptop"]');
await page.click('button:has-text("Add to Cart")');
await page.click('a:has-text("Checkout")');
await page.fill('#card-number', '4111111111111111');
await page.click('button:has-text("Pay")');
await expect(page.locator('.confirmation')).toContainText('Order confirmed');
});
Characteristics:
- Slowest (minutes)
- Test real user journeys
- Few (tens to low hundreds)
- Run nightly or before release
Choosing Your First Tool
Pick based on your tech stack:
| Stack | Unit Tests | Integration | E2E |
|---|---|---|---|
| JavaScript/Node | Jest | Jest + Supertest | Playwright/Cypress |
| Python | pytest | pytest | Playwright/pytest |
| Java | JUnit 5 | TestNG | Selenium |
| React | Jest + RTL | Jest + MSW | Playwright |
| Mobile | XCTest/JUnit | XCTest/Espresso | Appium |
For Web Testing: Playwright
Modern, reliable, great developer experience.
# Install
npm init playwright@latest
// tests/example.spec.js
const { test, expect } = require('@playwright/test');
test('homepage has title', async ({ page }) => {
await page.goto('https://example.com');
await expect(page).toHaveTitle(/Example/);
});
# Run tests
npx playwright test
For JavaScript Unit Tests: Jest
Most popular JavaScript testing framework.
npm install --save-dev jest
// math.js
function multiply(a, b) {
return a * b;
}
module.exports = { multiply };
// math.test.js
const { multiply } = require('./math');
test('multiplies two numbers', () => {
expect(multiply(3, 4)).toBe(12);
});
npm test
For Python: pytest
De facto standard for Python testing.
pip install pytest
# test_example.py
def test_string_uppercase():
assert "hello".upper() == "HELLO"
def test_list_contains():
fruits = ["apple", "banana", "cherry"]
assert "banana" in fruits
pytest
Writing Your First Automated Test
Let’s build a complete example from scratch.
Step 1: Set Up a Simple Application
// app.js - Express application
const express = require('express');
const app = express();
app.use(express.json());
let todos = [];
app.get('/todos', (req, res) => {
res.json(todos);
});
app.post('/todos', (req, res) => {
const todo = {
id: Date.now(),
text: req.body.text,
completed: false
};
todos.push(todo);
res.status(201).json(todo);
});
app.put('/todos/:id', (req, res) => {
const todo = todos.find(t => t.id === parseInt(req.params.id));
if (!todo) return res.status(404).json({ error: 'Not found' });
todo.completed = req.body.completed;
res.json(todo);
});
module.exports = app;
Step 2: Write Unit Tests
// tests/todo.test.js
const request = require('supertest');
const app = require('../app');
describe('Todo API', () => {
beforeEach(() => {
// Reset todos before each test
app.locals.todos = [];
});
test('GET /todos returns empty list initially', async () => {
const response = await request(app).get('/todos');
expect(response.status).toBe(200);
expect(response.body).toEqual([]);
});
test('POST /todos creates a new todo', async () => {
const response = await request(app)
.post('/todos')
.send({ text: 'Buy groceries' });
expect(response.status).toBe(201);
expect(response.body.text).toBe('Buy groceries');
expect(response.body.completed).toBe(false);
expect(response.body.id).toBeDefined();
});
test('PUT /todos/:id updates completion status', async () => {
// Create a todo first
const createResponse = await request(app)
.post('/todos')
.send({ text: 'Test todo' });
const todoId = createResponse.body.id;
// Update it
const updateResponse = await request(app)
.put(`/todos/${todoId}`)
.send({ completed: true });
expect(updateResponse.status).toBe(200);
expect(updateResponse.body.completed).toBe(true);
});
test('PUT /todos/:id returns 404 for non-existent todo', async () => {
const response = await request(app)
.put('/todos/99999')
.send({ completed: true });
expect(response.status).toBe(404);
});
});
Step 3: Run and Verify
npm test
# Output:
# PASS tests/todo.test.js
# Todo API
# ✓ GET /todos returns empty list initially (15ms)
# ✓ POST /todos creates a new todo (8ms)
# ✓ PUT /todos/:id updates completion status (5ms)
# ✓ PUT /todos/:id returns 404 for non-existent todo (3ms)
#
# Tests: 4 passed
Building a Test Suite
A single test file isn’t enough. Real projects need organized test suites.
Project Structure
project/
├── src/
│ ├── services/
│ │ ├── user.service.js
│ │ └── order.service.js
│ └── utils/
│ └── validation.js
├── tests/
│ ├── unit/
│ │ ├── services/
│ │ │ ├── user.service.test.js
│ │ │ └── order.service.test.js
│ │ └── utils/
│ │ └── validation.test.js
│ ├── integration/
│ │ └── api.test.js
│ └── e2e/
│ └── checkout.spec.js
├── jest.config.js
└── playwright.config.js
Shared Setup (Fixtures)
// tests/fixtures/users.js
const testUsers = {
admin: {
email: 'admin@example.com',
role: 'admin',
token: 'admin-token-123'
},
regular: {
email: 'user@example.com',
role: 'user',
token: 'user-token-456'
}
};
module.exports = { testUsers };
// tests/integration/admin.test.js
const { testUsers } = require('../fixtures/users');
test('admin can access admin panel', async () => {
const response = await request(app)
.get('/admin')
.set('Authorization', `Bearer ${testUsers.admin.token}`);
expect(response.status).toBe(200);
});
Test Data Builders
// tests/builders/user.builder.js
class UserBuilder {
constructor() {
this.user = {
email: 'default@example.com',
name: 'Default User',
role: 'user'
};
}
withEmail(email) {
this.user.email = email;
return this;
}
withRole(role) {
this.user.role = role;
return this;
}
asAdmin() {
this.user.role = 'admin';
return this;
}
build() {
return { ...this.user };
}
}
// Usage
const admin = new UserBuilder().asAdmin().withEmail('boss@company.com').build();
CI/CD Integration
Automated tests should run automatically on every code change.
GitHub Actions Example
# .github/workflows/test.yml
name: Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm test
- name: Run E2E tests
run: npx playwright test
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results/
Best Practices
1. Test Behavior, Not Implementation
// Bad - tests implementation details
test('adds item to internal array', () => {
cart.addItem(product);
expect(cart._items).toContain(product); // Accessing private state
});
// Good - tests behavior
test('added item appears in cart', () => {
cart.addItem(product);
expect(cart.getItems()).toContain(product);
});
2. One Assertion Per Test (Ideally)
// Bad - multiple unrelated assertions
test('user creation', () => {
const user = createUser('john@example.com');
expect(user.email).toBe('john@example.com');
expect(user.createdAt).toBeDefined();
expect(user.id).toMatch(/^usr_/);
expect(user.verified).toBe(false);
});
// Good - focused tests
test('new user has provided email', () => {
const user = createUser('john@example.com');
expect(user.email).toBe('john@example.com');
});
test('new user is not verified by default', () => {
const user = createUser('john@example.com');
expect(user.verified).toBe(false);
});
3. Make Tests Independent
// Bad - tests depend on execution order
let user;
test('creates user', () => {
user = createUser('test@example.com');
expect(user).toBeDefined();
});
test('fetches created user', () => {
const fetched = getUser(user.id); // Fails if first test doesn't run
expect(fetched.email).toBe('test@example.com');
});
// Good - each test sets up its own data
test('fetches user by id', () => {
const user = createUser('test@example.com');
const fetched = getUser(user.id);
expect(fetched.email).toBe('test@example.com');
});
4. Use Descriptive Names
// Bad
test('test1', () => { ... });
test('login', () => { ... });
// Good
test('user with valid credentials can login', () => { ... });
test('login fails with incorrect password', () => { ... });
test('locked account cannot login even with correct password', () => { ... });
Common Mistakes to Avoid
1. Flaky Tests
Tests that sometimes pass, sometimes fail.
Cause: Race conditions, timing dependencies, shared state.
// Flaky - depends on timing
test('shows notification', async () => {
triggerNotification();
await sleep(100); // Might not be enough
expect(screen.getByText('Success')).toBeVisible();
});
// Better - wait for element
test('shows notification', async () => {
triggerNotification();
await waitFor(() => {
expect(screen.getByText('Success')).toBeVisible();
});
});
2. Testing Third-Party Code
// Bad - testing lodash
test('lodash sorts correctly', () => {
expect(_.sortBy([3, 1, 2])).toEqual([1, 2, 3]);
});
// Good - test YOUR code that uses lodash
test('getTopUsers returns users sorted by score', () => {
const users = [
{ name: 'Alice', score: 50 },
{ name: 'Bob', score: 100 }
];
expect(getTopUsers(users)[0].name).toBe('Bob');
});
3. Over-Mocking
// Over-mocked - testing nothing real
test('processOrder', () => {
mockDatabase.save.mockResolvedValue(true);
mockPayment.charge.mockResolvedValue(true);
mockEmail.send.mockResolvedValue(true);
mockInventory.update.mockResolvedValue(true);
// What are we even testing?
const result = processOrder(order);
expect(result.success).toBe(true);
});
AI-Assisted Test Automation
AI tools can accelerate test development when used appropriately.
What AI does well:
- Generate test cases from function signatures
- Create test data variations
- Write boilerplate for common patterns
- Suggest edge cases you might miss
What still needs humans:
- Deciding what to test
- Verifying tests test the right thing
- Designing test architecture
- Debugging flaky tests
Useful prompt:
Write Jest tests for this function. Include tests for: valid inputs, invalid inputs, edge cases (empty, null, boundary values), and error handling. Use descriptive test names that explain the expected behavior.
FAQ
What is test automation?
Test automation uses software to execute tests automatically rather than manually. Scripts simulate user actions, call APIs, or invoke functions, then verify that results match expectations. This provides faster feedback, more consistent execution, and enables running thousands of tests that would be impractical manually.
Which tool is best for test automation beginners?
For web testing, Playwright or Cypress offer modern APIs, excellent documentation, and fast setup. For Python projects, pytest is the standard choice. For JavaScript/Node.js, Jest dominates. The best tool depends on your existing tech stack — learn what your team uses or what matches your application’s language.
How long does it take to learn test automation?
Basic test automation — writing simple tests that work — takes 2-4 weeks of focused practice. Writing maintainable, well-structured test suites typically takes 3-6 months of real project experience. Mastering CI/CD integration, test architecture, and handling complex scenarios takes 1-2 years of professional practice.
Should I learn Selenium or Playwright first?
For new projects, learn Playwright — it has a more modern API, built-in auto-waiting, better assertions, and excellent developer experience. Learn Selenium if you’re joining a team with existing Selenium tests or need to support older browsers. Both skills are valuable; Playwright is easier to start with.
Official Resources
See Also
- Selenium Tutorial for Beginners - Getting started with Selenium WebDriver
- Playwright Tutorial - Modern web testing with Playwright
- Jest Testing Tutorial - JavaScript unit testing with Jest
- pytest Tutorial - Python testing fundamentals
- Test Automation Pyramid Strategy - Balancing test types effectively
- Software Testing Tutorial - Testing fundamentals for beginners
