501 lines
11 KiB
Markdown
501 lines
11 KiB
Markdown
---
|
|
name: test-maintenance-patterns
|
|
description: Use when reducing test duplication, refactoring flaky tests, implementing page object patterns, managing test helpers, reducing test debt, or scaling test suites - provides refactoring strategies and maintainability patterns for long-term test sustainability
|
|
---
|
|
|
|
# Test Maintenance Patterns
|
|
|
|
## Overview
|
|
|
|
**Core principle:** Test code is production code. Apply the same quality standards: DRY, SOLID, refactoring.
|
|
|
|
**Rule:** If you can't understand a test in 30 seconds, refactor it. If a test is flaky, fix or delete it.
|
|
|
|
## Test Maintenance vs Writing Tests
|
|
|
|
| Activity | When | Goal |
|
|
|----------|------|------|
|
|
| **Writing tests** | New features, bug fixes | Add coverage |
|
|
| **Maintaining tests** | Test suite grows, flakiness increases | Reduce duplication, improve clarity, fix flakiness |
|
|
|
|
**Test debt indicators:**
|
|
- Tests take > 15 minutes to run
|
|
- > 5% flakiness rate
|
|
- Duplicate setup code across 10+ tests
|
|
- Tests break on unrelated changes
|
|
- Nobody understands old tests
|
|
|
|
---
|
|
|
|
## Page Object Pattern (E2E Tests)
|
|
|
|
**Problem:** Duplicated selectors across tests
|
|
|
|
```javascript
|
|
// ❌ BAD: Selectors duplicated everywhere
|
|
test('login', async ({ page }) => {
|
|
await page.fill('#email', 'user@example.com');
|
|
await page.fill('#password', 'password');
|
|
await page.click('button[type="submit"]');
|
|
});
|
|
|
|
test('forgot password', async ({ page }) => {
|
|
await page.fill('#email', 'user@example.com'); // Duplicated!
|
|
await page.click('a.forgot-password');
|
|
});
|
|
```
|
|
|
|
**Fix:** Page Object Pattern
|
|
|
|
```javascript
|
|
// pages/LoginPage.js
|
|
export class LoginPage {
|
|
constructor(page) {
|
|
this.page = page;
|
|
this.emailInput = page.locator('#email');
|
|
this.passwordInput = page.locator('#password');
|
|
this.submitButton = page.locator('button[type="submit"]');
|
|
this.forgotPasswordLink = page.locator('a.forgot-password');
|
|
}
|
|
|
|
async goto() {
|
|
await this.page.goto('/login');
|
|
}
|
|
|
|
async login(email, password) {
|
|
await this.emailInput.fill(email);
|
|
await this.passwordInput.fill(password);
|
|
await this.submitButton.click();
|
|
}
|
|
|
|
async clickForgotPassword() {
|
|
await this.forgotPasswordLink.click();
|
|
}
|
|
}
|
|
|
|
// tests/login.spec.js
|
|
import { LoginPage } from '../pages/LoginPage';
|
|
|
|
test('login', async ({ page }) => {
|
|
const loginPage = new LoginPage(page);
|
|
await loginPage.goto();
|
|
await loginPage.login('user@example.com', 'password');
|
|
|
|
await expect(page).toHaveURL('/dashboard');
|
|
});
|
|
|
|
test('forgot password', async ({ page }) => {
|
|
const loginPage = new LoginPage(page);
|
|
await loginPage.goto();
|
|
await loginPage.clickForgotPassword();
|
|
|
|
await expect(page).toHaveURL('/reset-password');
|
|
});
|
|
```
|
|
|
|
**Benefits:**
|
|
- Selectors in one place
|
|
- Tests read like documentation
|
|
- Changes to UI require one-line fix
|
|
|
|
---
|
|
|
|
## Test Data Builders (Integration/Unit Tests)
|
|
|
|
**Problem:** Duplicate test data setup
|
|
|
|
```python
|
|
# ❌ BAD: Duplicated setup
|
|
def test_order_total():
|
|
order = Order(
|
|
id=1,
|
|
user_id=123,
|
|
items=[Item(sku="WIDGET", quantity=2, price=10.0)],
|
|
shipping=5.0,
|
|
tax=1.5
|
|
)
|
|
assert order.total() == 26.5
|
|
|
|
def test_order_discounts():
|
|
order = Order( # Same setup!
|
|
id=2,
|
|
user_id=123,
|
|
items=[Item(sku="WIDGET", quantity=2, price=10.0)],
|
|
shipping=5.0,
|
|
tax=1.5
|
|
)
|
|
order.apply_discount(10)
|
|
assert order.total() == 24.0
|
|
```
|
|
|
|
**Fix:** Builder Pattern
|
|
|
|
```python
|
|
# test_builders.py
|
|
class OrderBuilder:
|
|
def __init__(self):
|
|
self._id = 1
|
|
self._user_id = 123
|
|
self._items = []
|
|
self._shipping = 0.0
|
|
self._tax = 0.0
|
|
|
|
def with_id(self, id):
|
|
self._id = id
|
|
return self
|
|
|
|
def with_items(self, *items):
|
|
self._items = list(items)
|
|
return self
|
|
|
|
def with_shipping(self, amount):
|
|
self._shipping = amount
|
|
return self
|
|
|
|
def with_tax(self, amount):
|
|
self._tax = amount
|
|
return self
|
|
|
|
def build(self):
|
|
return Order(
|
|
id=self._id,
|
|
user_id=self._user_id,
|
|
items=self._items,
|
|
shipping=self._shipping,
|
|
tax=self._tax
|
|
)
|
|
|
|
# tests/test_orders.py
|
|
def test_order_total():
|
|
order = (OrderBuilder()
|
|
.with_items(Item(sku="WIDGET", quantity=2, price=10.0))
|
|
.with_shipping(5.0)
|
|
.with_tax(1.5)
|
|
.build())
|
|
|
|
assert order.total() == 26.5
|
|
|
|
def test_order_discounts():
|
|
order = (OrderBuilder()
|
|
.with_items(Item(sku="WIDGET", quantity=2, price=10.0))
|
|
.with_shipping(5.0)
|
|
.with_tax(1.5)
|
|
.build())
|
|
|
|
order.apply_discount(10)
|
|
assert order.total() == 24.0
|
|
```
|
|
|
|
**Benefits:**
|
|
- Readable test data creation
|
|
- Easy to customize per test
|
|
- Defaults handle common cases
|
|
|
|
---
|
|
|
|
## Shared Fixtures (pytest)
|
|
|
|
**Problem:** Setup code duplicated across tests
|
|
|
|
```python
|
|
# ❌ BAD
|
|
def test_user_creation():
|
|
db = setup_database()
|
|
user_repo = UserRepository(db)
|
|
user = user_repo.create(email="alice@example.com")
|
|
assert user.id is not None
|
|
cleanup_database(db)
|
|
|
|
def test_user_deletion():
|
|
db = setup_database() # Duplicated!
|
|
user_repo = UserRepository(db)
|
|
user = user_repo.create(email="bob@example.com")
|
|
user_repo.delete(user.id)
|
|
assert user_repo.get(user.id) is None
|
|
cleanup_database(db)
|
|
```
|
|
|
|
**Fix:** Fixtures
|
|
|
|
```python
|
|
# conftest.py
|
|
import pytest
|
|
|
|
@pytest.fixture
|
|
def db():
|
|
"""Provide database connection with auto-cleanup."""
|
|
database = setup_database()
|
|
yield database
|
|
cleanup_database(database)
|
|
|
|
@pytest.fixture
|
|
def user_repo(db):
|
|
"""Provide user repository."""
|
|
return UserRepository(db)
|
|
|
|
# tests/test_users.py
|
|
def test_user_creation(user_repo):
|
|
user = user_repo.create(email="alice@example.com")
|
|
assert user.id is not None
|
|
|
|
def test_user_deletion(user_repo):
|
|
user = user_repo.create(email="bob@example.com")
|
|
user_repo.delete(user.id)
|
|
assert user_repo.get(user.id) is None
|
|
```
|
|
|
|
---
|
|
|
|
## Reducing Test Duplication
|
|
|
|
### Custom Matchers/Assertions
|
|
|
|
**Problem:** Complex assertions repeated
|
|
|
|
```python
|
|
# ❌ BAD: Repeated validation logic
|
|
def test_valid_user():
|
|
user = create_user()
|
|
assert user.id is not None
|
|
assert '@' in user.email
|
|
assert len(user.name) > 0
|
|
assert user.created_at is not None
|
|
|
|
def test_another_valid_user():
|
|
user = create_admin()
|
|
assert user.id is not None # Same validations!
|
|
assert '@' in user.email
|
|
assert len(user.name) > 0
|
|
assert user.created_at is not None
|
|
```
|
|
|
|
**Fix:** Custom assertion helpers
|
|
|
|
```python
|
|
# test_helpers.py
|
|
def assert_valid_user(user):
|
|
"""Assert user object is valid."""
|
|
assert user.id is not None, "User must have ID"
|
|
assert '@' in user.email, "Email must contain @"
|
|
assert len(user.name) > 0, "Name cannot be empty"
|
|
assert user.created_at is not None, "User must have creation timestamp"
|
|
|
|
# tests/test_users.py
|
|
def test_valid_user():
|
|
user = create_user()
|
|
assert_valid_user(user)
|
|
|
|
def test_another_valid_user():
|
|
user = create_admin()
|
|
assert_valid_user(user)
|
|
```
|
|
|
|
---
|
|
|
|
## Handling Flaky Tests
|
|
|
|
### Strategy 1: Fix the Root Cause
|
|
|
|
**Flaky test symptoms:**
|
|
- Passes 95/100 runs
|
|
- Fails with different errors
|
|
- Fails only in CI
|
|
|
|
**Root causes:**
|
|
- Race conditions (see flaky-test-prevention skill)
|
|
- Shared state (see test-isolation-fundamentals skill)
|
|
- Timing assumptions
|
|
|
|
**Fix:** Use condition-based waiting, isolate state
|
|
|
|
---
|
|
|
|
### Strategy 2: Quarantine Pattern
|
|
|
|
**For tests that can't be fixed immediately:**
|
|
|
|
```python
|
|
# Mark as flaky, run separately
|
|
@pytest.mark.flaky
|
|
def test_sometimes_fails():
|
|
# Test code
|
|
pass
|
|
```
|
|
|
|
```bash
|
|
# Run stable tests only
|
|
pytest -m "not flaky"
|
|
|
|
# Run flaky tests separately (don't block CI)
|
|
pytest -m flaky --count=3 # Retry up to 3 times
|
|
```
|
|
|
|
**Rule:** Quarantined tests must have tracking issue. Fix within 30 days or delete.
|
|
|
|
---
|
|
|
|
### Strategy 3: Delete If Unfixable
|
|
|
|
**When to delete:**
|
|
- Test is flaky AND nobody understands it
|
|
- Test has been disabled for > 90 days
|
|
- Test duplicates coverage from other tests
|
|
|
|
**Better to have:** 100 reliable tests than 150 tests with 10 flaky ones
|
|
|
|
---
|
|
|
|
## Refactoring Test Suites
|
|
|
|
### Identify Slow Tests
|
|
|
|
```bash
|
|
# pytest: Show slowest 10 tests
|
|
pytest --durations=10
|
|
|
|
# Output:
|
|
# 10.23s call test_integration_checkout.py::test_full_checkout
|
|
# 8.45s call test_api.py::test_payment_flow
|
|
# ...
|
|
```
|
|
|
|
**Action:** Optimize or split into integration/E2E categories
|
|
|
|
---
|
|
|
|
### Parallelize Tests
|
|
|
|
```bash
|
|
# pytest: Run tests in parallel
|
|
pytest -n 4 # Use 4 CPU cores
|
|
|
|
# Jest: Run tests in parallel (default)
|
|
jest --maxWorkers=4
|
|
```
|
|
|
|
**Requirements:**
|
|
- Tests must be isolated (no shared state)
|
|
- See test-isolation-fundamentals skill
|
|
|
|
---
|
|
|
|
### Split Test Suites
|
|
|
|
```ini
|
|
# pytest.ini
|
|
[pytest]
|
|
markers =
|
|
unit: Unit tests (fast, isolated)
|
|
integration: Integration tests (medium speed, real DB)
|
|
e2e: End-to-end tests (slow, full system)
|
|
```
|
|
|
|
```yaml
|
|
# CI: Run test categories separately
|
|
jobs:
|
|
unit:
|
|
run: pytest -m unit # Fast, every commit
|
|
|
|
integration:
|
|
run: pytest -m integration # Medium, every PR
|
|
|
|
e2e:
|
|
run: pytest -m e2e # Slow, before merge
|
|
```
|
|
|
|
---
|
|
|
|
## Anti-Patterns Catalog
|
|
|
|
### ❌ God Test
|
|
|
|
**Symptom:** One test does everything
|
|
|
|
```python
|
|
def test_entire_checkout_flow():
|
|
# 300 lines testing: login, browse, add to cart, checkout, payment, email
|
|
pass
|
|
```
|
|
|
|
**Why bad:** Failure doesn't indicate what broke
|
|
|
|
**Fix:** Split into focused tests
|
|
|
|
---
|
|
|
|
### ❌ Testing Implementation Details
|
|
|
|
**Symptom:** Tests break when refactoring internal code
|
|
|
|
```python
|
|
# ❌ BAD: Testing internal method
|
|
def test_order_calculation():
|
|
order = Order()
|
|
order._calculate_subtotal() # Private method!
|
|
assert order.subtotal == 100
|
|
```
|
|
|
|
**Fix:** Test public interface only
|
|
|
|
```python
|
|
# ✅ GOOD
|
|
def test_order_total():
|
|
order = Order(items=[...])
|
|
assert order.total() == 108 # Public method
|
|
```
|
|
|
|
---
|
|
|
|
### ❌ Commented-Out Tests
|
|
|
|
**Symptom:** Tests disabled with comments
|
|
|
|
```python
|
|
# def test_something():
|
|
# # This test is broken, commented out for now
|
|
# pass
|
|
```
|
|
|
|
**Fix:** Delete or fix. Create GitHub issue if needs fixing later.
|
|
|
|
---
|
|
|
|
## Test Maintenance Checklist
|
|
|
|
**Monthly:**
|
|
- [ ] Review flaky test rate (should be < 1%)
|
|
- [ ] Check build time trend (should not increase > 5%/month)
|
|
- [ ] Identify duplicate setup code (refactor into fixtures)
|
|
- [ ] Run mutation testing (validate test quality)
|
|
|
|
**Quarterly:**
|
|
- [ ] Review test coverage (identify gaps)
|
|
- [ ] Audit for commented-out tests (delete)
|
|
- [ ] Check for unused fixtures (delete)
|
|
- [ ] Refactor slowest 10 tests
|
|
|
|
**Annually:**
|
|
- [ ] Review entire test architecture
|
|
- [ ] Update testing strategy for new patterns
|
|
- [ ] Train team on new testing practices
|
|
|
|
---
|
|
|
|
## Bottom Line
|
|
|
|
**Treat test code as production code. Refactor duplication, fix flakiness, delete dead tests.**
|
|
|
|
**Key patterns:**
|
|
- Page Objects (E2E tests)
|
|
- Builder Pattern (test data)
|
|
- Shared Fixtures (setup/teardown)
|
|
- Custom Assertions (complex validations)
|
|
|
|
**Maintenance rules:**
|
|
- Fix flaky tests immediately or quarantine
|
|
- Refactor duplicated code
|
|
- Delete commented-out tests
|
|
- Split slow test suites
|
|
|
|
**If your tests are flaky, slow, or nobody understands them, invest in maintenance before adding more tests. Test debt compounds like technical debt.**
|