Post

Testing Strategies: Unit, Integration, and E2E Testing

Introduction

Software testing is a critical aspect of the development lifecycle that ensures code quality, reliability, and maintainability. A well-designed testing strategy combines different types of tests to create a robust safety net that catches bugs early and prevents regressions.

The testing pyramid is a fundamental concept that guides how we should distribute our testing efforts across different test types. At its base are unit tests (fast, isolated, numerous), in the middle are integration tests (moderate speed, test interactions), and at the top are end-to-end tests (slow, comprehensive, fewer in number).

In this comprehensive guide, we’ll explore unit testing, integration testing, and end-to-end testing strategies, along with test doubles, test-driven development, and best practices for building a maintainable test suite.

The Testing Pyramid

Understanding the Pyramid Structure

The testing pyramid, introduced by Mike Cohn, suggests that you should have:

  1. Many Unit Tests (70-80%): Fast, isolated tests that verify individual components
  2. Some Integration Tests (15-20%): Tests that verify interactions between components
  3. Few E2E Tests (5-10%): Tests that verify the entire system from the user’s perspective

Why This Distribution Matters

  • Speed: Unit tests run in milliseconds, while E2E tests can take seconds or minutes
  • Reliability: Unit tests are deterministic, while E2E tests can be flaky
  • Debugging: Unit tests pinpoint exact failures, while E2E tests require investigation
  • Maintenance: Unit tests are easier to maintain than complex E2E scenarios
  • Cost: The higher you go in the pyramid, the more expensive tests become

Unit Testing

What Are Unit Tests

Unit tests verify the smallest testable parts of an application (functions, methods, classes) in isolation from external dependencies. They should be fast, independent, and focused on a single behavior.

Characteristics of Good Unit Tests

  1. Fast: Execute in milliseconds
  2. Isolated: No dependencies on databases, file systems, or external services
  3. Repeatable: Same results every time
  4. Self-validating: Clear pass/fail without manual inspection
  5. Timely: Written before or alongside production code

Unit Testing Patterns

AAA Pattern (Arrange, Act, Assert)

The AAA pattern is the most common structure for unit tests:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# Python - Unit Test with AAA Pattern
import unittest
from calculator import Calculator

class TestCalculator(unittest.TestCase):
    def test_add_two_positive_numbers(self):
        # Arrange
        calculator = Calculator()
        a = 5
        b = 3
        
        # Act
        result = calculator.add(a, b)
        
        # Assert
        self.assertEqual(result, 8)
    
    def test_add_negative_numbers(self):
        # Arrange
        calculator = Calculator()
        
        # Act
        result = calculator.add(-5, -3)
        
        # Assert
        self.assertEqual(result, -8)
    
    def test_divide_by_zero_raises_exception(self):
        # Arrange
        calculator = Calculator()
        
        # Act & Assert
        with self.assertRaises(ZeroDivisionError):
            calculator.divide(10, 0)

if __name__ == '__main__':
    unittest.main()

Parameterized Tests

Reduce duplication by testing multiple inputs with the same logic:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# Python - Parameterized Tests
import unittest
from parameterized import parameterized

class TestCalculator(unittest.TestCase):
    @parameterized.expand([
        (2, 3, 5),
        (0, 0, 0),
        (-1, 1, 0),
        (100, 200, 300),
        (-5, -5, -10)
    ])
    def test_add_various_inputs(self, a, b, expected):
        calculator = Calculator()
        result = calculator.add(a, b)
        self.assertEqual(result, expected)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// JavaScript - Parameterized Tests with Jest
describe('Calculator', () => {
  describe('add', () => {
    test.each([
      [2, 3, 5],
      [0, 0, 0],
      [-1, 1, 0],
      [100, 200, 300],
      [-5, -5, -10]
    ])('add(%i, %i) should return %i', (a, b, expected) => {
      const calculator = new Calculator();
      const result = calculator.add(a, b);
      expect(result).toBe(expected);
    });
  });
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// C# - Parameterized Tests with xUnit
public class CalculatorTests
{
    [Theory]
    [InlineData(2, 3, 5)]
    [InlineData(0, 0, 0)]
    [InlineData(-1, 1, 0)]
    [InlineData(100, 200, 300)]
    [InlineData(-5, -5, -10)]
    public void Add_VariousInputs_ReturnsExpectedResult(int a, int b, int expected)
    {
        // Arrange
        var calculator = new Calculator();
        
        // Act
        var result = calculator.Add(a, b);
        
        // Assert
        Assert.Equal(expected, result);
    }
}

Testing Complex Logic

Testing Business Rules

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# Python - Testing Business Logic
class OrderProcessor:
    def calculate_total(self, items, customer_type):
        subtotal = sum(item.price * item.quantity for item in items)
        
        # Apply discounts based on customer type
        if customer_type == "VIP":
            discount = 0.20
        elif customer_type == "PREMIUM":
            discount = 0.10
        else:
            discount = 0
        
        total = subtotal * (1 - discount)
        
        # Free shipping for orders over $100
        shipping = 0 if total >= 100 else 10
        
        return total + shipping

# Unit Tests
class TestOrderProcessor(unittest.TestCase):
    def setUp(self):
        self.processor = OrderProcessor()
        self.items = [
            Item(price=50, quantity=1),
            Item(price=30, quantity=2)
        ]
    
    def test_regular_customer_under_100_includes_shipping(self):
        # Arrange
        items = [Item(price=50, quantity=1)]
        
        # Act
        total = self.processor.calculate_total(items, "REGULAR")
        
        # Assert
        self.assertEqual(total, 60)  # 50 + 10 shipping
    
    def test_vip_customer_gets_20_percent_discount(self):
        # Act
        total = self.processor.calculate_total(self.items, "VIP")
        
        # Assert
        # Items: 50 + 60 = 110
        # After 20% discount: 88
        # Free shipping (over 100 before discount)
        self.assertEqual(total, 88)
    
    def test_free_shipping_for_orders_over_100(self):
        # Arrange
        items = [Item(price=60, quantity=2)]
        
        # Act
        total = self.processor.calculate_total(items, "REGULAR")
        
        # Assert
        self.assertEqual(total, 120)  # No shipping charge

Testing Error Handling

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// JavaScript - Testing Error Handling
class UserService {
  createUser(userData) {
    if (!userData.email) {
      throw new ValidationError('Email is required');
    }
    
    if (!this.isValidEmail(userData.email)) {
      throw new ValidationError('Invalid email format');
    }
    
    if (this.userExists(userData.email)) {
      throw new ConflictError('User already exists');
    }
    
    return this.repository.save(userData);
  }
}

// Tests
describe('UserService', () => {
  let userService;
  let mockRepository;
  
  beforeEach(() => {
    mockRepository = {
      save: jest.fn(),
      findByEmail: jest.fn()
    };
    userService = new UserService(mockRepository);
  });
  
  describe('createUser', () => {
    it('should throw ValidationError when email is missing', () => {
      const userData = { name: 'John' };
      
      expect(() => {
        userService.createUser(userData);
      }).toThrow(ValidationError);
    });
    
    it('should throw ValidationError when email format is invalid', () => {
      const userData = { email: 'invalid-email' };
      
      expect(() => {
        userService.createUser(userData);
      }).toThrow(ValidationError);
      expect(() => {
        userService.createUser(userData);
      }).toThrow('Invalid email format');
    });
    
    it('should throw ConflictError when user already exists', () => {
      mockRepository.findByEmail.mockReturnValue({ id: 1 });
      const userData = { email: 'existing@example.com' };
      
      expect(() => {
        userService.createUser(userData);
      }).toThrow(ConflictError);
    });
    
    it('should save user when data is valid', () => {
      mockRepository.findByEmail.mockReturnValue(null);
      mockRepository.save.mockReturnValue({ id: 1, email: 'new@example.com' });
      
      const userData = { email: 'new@example.com', name: 'John' };
      const result = userService.createUser(userData);
      
      expect(mockRepository.save).toHaveBeenCalledWith(userData);
      expect(result).toHaveProperty('id');
    });
  });
});

Test Doubles: Mocks, Stubs, and Fakes

Understanding Test Doubles

Test doubles are objects that stand in for real dependencies in unit tests. They help isolate the code under test.

Types of Test Doubles

1. Stubs

Stubs provide predetermined responses to method calls:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// C# - Using Stubs
public interface IEmailService
{
    bool SendEmail(string to, string subject, string body);
}

public class OrderService
{
    private readonly IEmailService _emailService;
    
    public OrderService(IEmailService emailService)
    {
        _emailService = emailService;
    }
    
    public void PlaceOrder(Order order)
    {
        // Process order...
        
        // Send confirmation email
        _emailService.SendEmail(
            order.CustomerEmail,
            "Order Confirmation",
            $"Your order #{order.Id} has been placed"
        );
    }
}

// Test with Stub
public class OrderServiceTests
{
    [Fact]
    public void PlaceOrder_SendsConfirmationEmail()
    {
        // Arrange
        var emailServiceStub = new EmailServiceStub();
        var orderService = new OrderService(emailServiceStub);
        var order = new Order { Id = 123, CustomerEmail = "test@example.com" };
        
        // Act
        orderService.PlaceOrder(order);
        
        // Assert
        Assert.True(emailServiceStub.EmailWasSent);
        Assert.Equal("test@example.com", emailServiceStub.LastRecipient);
    }
}

// Simple Stub Implementation
public class EmailServiceStub : IEmailService
{
    public bool EmailWasSent { get; private set; }
    public string LastRecipient { get; private set; }
    
    public bool SendEmail(string to, string subject, string body)
    {
        EmailWasSent = true;
        LastRecipient = to;
        return true;
    }
}

2. Mocks

Mocks verify that specific interactions occurred:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# Python - Using Mocks
from unittest.mock import Mock, MagicMock
import unittest

class PaymentProcessor:
    def __init__(self, payment_gateway, notification_service):
        self.payment_gateway = payment_gateway
        self.notification_service = notification_service
    
    def process_payment(self, amount, card_token):
        result = self.payment_gateway.charge(amount, card_token)
        
        if result.success:
            self.notification_service.send_receipt(result.transaction_id)
            return True
        else:
            self.notification_service.send_failure_notice(result.error)
            return False

class TestPaymentProcessor(unittest.TestCase):
    def test_successful_payment_sends_receipt(self):
        # Arrange
        mock_gateway = Mock()
        mock_gateway.charge.return_value = Mock(
            success=True,
            transaction_id="txn_123"
        )
        
        mock_notification = Mock()
        processor = PaymentProcessor(mock_gateway, mock_notification)
        
        # Act
        result = processor.process_payment(100, "card_token")
        
        # Assert
        mock_gateway.charge.assert_called_once_with(100, "card_token")
        mock_notification.send_receipt.assert_called_once_with("txn_123")
        mock_notification.send_failure_notice.assert_not_called()
        self.assertTrue(result)
    
    def test_failed_payment_sends_failure_notice(self):
        # Arrange
        mock_gateway = Mock()
        mock_gateway.charge.return_value = Mock(
            success=False,
            error="Insufficient funds"
        )
        
        mock_notification = Mock()
        processor = PaymentProcessor(mock_gateway, mock_notification)
        
        # Act
        result = processor.process_payment(100, "card_token")
        
        # Assert
        mock_notification.send_failure_notice.assert_called_once_with("Insufficient funds")
        mock_notification.send_receipt.assert_not_called()
        self.assertFalse(result)

3. Fakes

Fakes have working implementations but are simplified:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// JavaScript - Using Fakes
class InMemoryUserRepository {
  constructor() {
    this.users = new Map();
    this.nextId = 1;
  }
  
  save(user) {
    if (!user.id) {
      user.id = this.nextId++;
    }
    this.users.set(user.id, { ...user });
    return user;
  }
  
  findById(id) {
    return this.users.get(id) || null;
  }
  
  findByEmail(email) {
    return Array.from(this.users.values())
      .find(user => user.email === email) || null;
  }
  
  clear() {
    this.users.clear();
    this.nextId = 1;
  }
}

// Using the Fake in Tests
describe('UserService with Fake Repository', () => {
  let userService;
  let fakeRepository;
  
  beforeEach(() => {
    fakeRepository = new InMemoryUserRepository();
    userService = new UserService(fakeRepository);
  });
  
  afterEach(() => {
    fakeRepository.clear();
  });
  
  it('should save and retrieve user', () => {
    const userData = { email: 'test@example.com', name: 'Test User' };
    
    const savedUser = userService.createUser(userData);
    const retrievedUser = userService.getUserById(savedUser.id);
    
    expect(retrievedUser).toEqual(savedUser);
  });
  
  it('should not allow duplicate emails', () => {
    const userData = { email: 'test@example.com', name: 'Test User' };
    
    userService.createUser(userData);
    
    expect(() => {
      userService.createUser(userData);
    }).toThrow('User already exists');
  });
});

Integration Testing

What Are Integration Tests

Integration tests verify that multiple components work together correctly. They test the interactions between modules, services, databases, and external systems.

Database Integration Tests

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# Python - Database Integration Tests with SQLAlchemy
import unittest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from models import Base, User
from repositories import UserRepository

class TestUserRepositoryIntegration(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        # Create in-memory SQLite database
        cls.engine = create_engine('sqlite:///:memory:')
        Base.metadata.create_all(cls.engine)
        cls.Session = sessionmaker(bind=cls.engine)
    
    def setUp(self):
        self.session = self.Session()
        self.repository = UserRepository(self.session)
    
    def tearDown(self):
        self.session.rollback()
        self.session.close()
    
    @classmethod
    def tearDownClass(cls):
        Base.metadata.drop_all(cls.engine)
    
    def test_create_and_retrieve_user(self):
        # Arrange
        user_data = {
            'username': 'testuser',
            'email': 'test@example.com',
            'first_name': 'Test',
            'last_name': 'User'
        }
        
        # Act
        created_user = self.repository.create(user_data)
        self.session.commit()
        
        retrieved_user = self.repository.get_by_id(created_user.id)
        
        # Assert
        self.assertIsNotNone(retrieved_user)
        self.assertEqual(retrieved_user.username, 'testuser')
        self.assertEqual(retrieved_user.email, 'test@example.com')
    
    def test_update_user_email(self):
        # Arrange
        user = self.repository.create({
            'username': 'testuser',
            'email': 'old@example.com'
        })
        self.session.commit()
        
        # Act
        user.email = 'new@example.com'
        self.repository.update(user)
        self.session.commit()
        
        # Verify
        updated_user = self.repository.get_by_id(user.id)
        self.assertEqual(updated_user.email, 'new@example.com')
    
    def test_delete_user(self):
        # Arrange
        user = self.repository.create({'username': 'testuser', 'email': 'test@example.com'})
        self.session.commit()
        user_id = user.id
        
        # Act
        self.repository.delete(user_id)
        self.session.commit()
        
        # Assert
        deleted_user = self.repository.get_by_id(user_id)
        self.assertIsNone(deleted_user)

API Integration Tests

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
// C# - API Integration Tests with WebApplicationFactory
public class ApiIntegrationTests : IClassFixture<WebApplicationFactory<Startup>>
{
    private readonly WebApplicationFactory<Startup> _factory;
    private readonly HttpClient _client;
    
    public ApiIntegrationTests(WebApplicationFactory<Startup> factory)
    {
        _factory = factory;
        _client = factory.CreateClient();
    }
    
    [Fact]
    public async Task GetUsers_ReturnsSuccessAndCorrectContentType()
    {
        // Act
        var response = await _client.GetAsync("/api/users");
        
        // Assert
        response.EnsureSuccessStatusCode();
        Assert.Equal("application/json; charset=utf-8", 
            response.Content.Headers.ContentType.ToString());
    }
    
    [Fact]
    public async Task CreateUser_WithValidData_ReturnsCreatedUser()
    {
        // Arrange
        var userData = new
        {
            username = "newuser",
            email = "newuser@example.com",
            firstName = "New",
            lastName = "User"
        };
        
        var content = new StringContent(
            JsonSerializer.Serialize(userData),
            Encoding.UTF8,
            "application/json"
        );
        
        // Act
        var response = await _client.PostAsync("/api/users", content);
        
        // Assert
        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        
        var responseBody = await response.Content.ReadAsStringAsync();
        var createdUser = JsonSerializer.Deserialize<UserDto>(responseBody);
        
        Assert.NotNull(createdUser);
        Assert.Equal("newuser", createdUser.Username);
        Assert.NotEqual(0, createdUser.Id);
    }
    
    [Fact]
    public async Task UpdateUser_WithValidData_ReturnsUpdatedUser()
    {
        // Arrange - Create a user first
        var createData = new { username = "testuser", email = "test@example.com" };
        var createResponse = await _client.PostAsync("/api/users", 
            new StringContent(JsonSerializer.Serialize(createData), Encoding.UTF8, "application/json"));
        var createdUser = JsonSerializer.Deserialize<UserDto>(
            await createResponse.Content.ReadAsStringAsync());
        
        // Update data
        var updateData = new { email = "updated@example.com" };
        var updateContent = new StringContent(
            JsonSerializer.Serialize(updateData),
            Encoding.UTF8,
            "application/json"
        );
        
        // Act
        var response = await _client.PutAsync($"/api/users/{createdUser.Id}", updateContent);
        
        // Assert
        response.EnsureSuccessStatusCode();
        var updatedUser = JsonSerializer.Deserialize<UserDto>(
            await response.Content.ReadAsStringAsync());
        Assert.Equal("updated@example.com", updatedUser.Email);
    }
}

Service Integration Tests

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
// JavaScript - Service Integration Tests
const request = require('supertest');
const { setupTestDatabase, teardownTestDatabase } = require('./test-helpers');
const app = require('../app');

describe('Order Service Integration Tests', () => {
  let db;
  
  beforeAll(async () => {
    db = await setupTestDatabase();
  });
  
  afterAll(async () => {
    await teardownTestDatabase(db);
  });
  
  beforeEach(async () => {
    await db.query('DELETE FROM orders');
    await db.query('DELETE FROM products');
    await db.query('DELETE FROM customers');
  });
  
  describe('POST /api/orders', () => {
    it('should create order with valid data', async () => {
      // Arrange - Setup test data
      const customer = await db.query(
        'INSERT INTO customers (name, email) VALUES ($1, $2) RETURNING *',
        ['Test Customer', 'test@example.com']
      );
      
      const product = await db.query(
        'INSERT INTO products (name, price, stock) VALUES ($1, $2, $3) RETURNING *',
        ['Test Product', 29.99, 100]
      );
      
      const orderData = {
        customerId: customer.rows[0].id,
        items: [
          {
            productId: product.rows[0].id,
            quantity: 2
          }
        ]
      };
      
      // Act
      const response = await request(app)
        .post('/api/orders')
        .send(orderData)
        .expect(201);
      
      // Assert
      expect(response.body).toHaveProperty('id');
      expect(response.body.total).toBe(59.98);
      expect(response.body.items).toHaveLength(1);
      
      // Verify database state
      const orders = await db.query('SELECT * FROM orders WHERE id = $1', [response.body.id]);
      expect(orders.rows).toHaveLength(1);
    });
    
    it('should reduce product stock after order', async () => {
      // Arrange
      const customer = await db.query(
        'INSERT INTO customers (name, email) VALUES ($1, $2) RETURNING *',
        ['Test Customer', 'test@example.com']
      );
      
      const product = await db.query(
        'INSERT INTO products (name, price, stock) VALUES ($1, $2, $3) RETURNING *',
        ['Test Product', 29.99, 100]
      );
      
      const orderData = {
        customerId: customer.rows[0].id,
        items: [{ productId: product.rows[0].id, quantity: 5 }]
      };
      
      // Act
      await request(app)
        .post('/api/orders')
        .send(orderData)
        .expect(201);
      
      // Assert
      const updatedProduct = await db.query(
        'SELECT stock FROM products WHERE id = $1',
        [product.rows[0].id]
      );
      expect(updatedProduct.rows[0].stock).toBe(95);
    });
    
    it('should fail when insufficient stock', async () => {
      // Arrange
      const customer = await db.query(
        'INSERT INTO customers (name, email) VALUES ($1, $2) RETURNING *',
        ['Test Customer', 'test@example.com']
      );
      
      const product = await db.query(
        'INSERT INTO products (name, price, stock) VALUES ($1, $2, $3) RETURNING *',
        ['Test Product', 29.99, 5]
      );
      
      const orderData = {
        customerId: customer.rows[0].id,
        items: [{ productId: product.rows[0].id, quantity: 10 }]
      };
      
      // Act
      const response = await request(app)
        .post('/api/orders')
        .send(orderData)
        .expect(400);
      
      // Assert
      expect(response.body.error).toContain('Insufficient stock');
      
      // Verify stock unchanged
      const unchangedProduct = await db.query(
        'SELECT stock FROM products WHERE id = $1',
        [product.rows[0].id]
      );
      expect(unchangedProduct.rows[0].stock).toBe(5);
    });
  });
});

End-to-End (E2E) Testing

What Are E2E Tests

E2E tests verify complete user workflows from start to finish, testing the entire application stack including UI, API, database, and external services.

E2E Testing with Playwright

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
// JavaScript - E2E Tests with Playwright
const { test, expect } = require('@playwright/test');

test.describe('User Registration Flow', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:3000');
  });
  
  test('should allow new user to register', async ({ page }) => {
    // Navigate to registration page
    await page.click('text=Sign Up');
    await expect(page).toHaveURL(/.*\/register/);
    
    // Fill registration form
    await page.fill('input[name="username"]', 'newuser');
    await page.fill('input[name="email"]', 'newuser@example.com');
    await page.fill('input[name="password"]', 'SecurePass123!');
    await page.fill('input[name="confirmPassword"]', 'SecurePass123!');
    
    // Submit form
    await page.click('button[type="submit"]');
    
    // Wait for success message
    await expect(page.locator('.success-message')).toContainText('Registration successful');
    
    // Verify redirect to dashboard
    await expect(page).toHaveURL(/.*\/dashboard/);
    await expect(page.locator('h1')).toContainText('Welcome, newuser');
  });
  
  test('should show validation errors for invalid input', async ({ page }) => {
    await page.click('text=Sign Up');
    
    // Submit empty form
    await page.click('button[type="submit"]');
    
    // Check for validation errors
    await expect(page.locator('.error-message')).toContainText('Username is required');
    await expect(page.locator('.error-message')).toContainText('Email is required');
    
    // Fill invalid email
    await page.fill('input[name="email"]', 'invalid-email');
    await page.blur('input[name="email"]');
    
    await expect(page.locator('.error-message')).toContainText('Invalid email format');
  });
  
  test('should prevent registration with existing email', async ({ page }) => {
    await page.click('text=Sign Up');
    
    await page.fill('input[name="username"]', 'anotheruser');
    await page.fill('input[name="email"]', 'existing@example.com');
    await page.fill('input[name="password"]', 'SecurePass123!');
    await page.fill('input[name="confirmPassword"]', 'SecurePass123!');
    
    await page.click('button[type="submit"]');
    
    await expect(page.locator('.error-message')).toContainText('Email already registered');
  });
});

test.describe('E-commerce Shopping Flow', () => {
  test('complete purchase flow', async ({ page }) => {
    // Login
    await page.goto('http://localhost:3000/login');
    await page.fill('input[name="email"]', 'testuser@example.com');
    await page.fill('input[name="password"]', 'password123');
    await page.click('button[type="submit"]');
    
    // Browse products
    await page.goto('http://localhost:3000/products');
    await expect(page.locator('.product-card')).toHaveCount(10);
    
    // Add product to cart
    await page.click('.product-card:first-child .add-to-cart-btn');
    await expect(page.locator('.cart-badge')).toContainText('1');
    
    // View cart
    await page.click('.cart-icon');
    await expect(page).toHaveURL(/.*\/cart/);
    await expect(page.locator('.cart-item')).toHaveCount(1);
    
    // Update quantity
    await page.selectOption('.quantity-select', '3');
    await expect(page.locator('.cart-total')).toContainText('$89.97');
    
    // Proceed to checkout
    await page.click('button:has-text("Checkout")');
    await expect(page).toHaveURL(/.*\/checkout/);
    
    // Fill shipping information
    await page.fill('input[name="address"]', '123 Test St');
    await page.fill('input[name="city"]', 'Test City');
    await page.fill('input[name="zipCode"]', '12345');
    
    // Fill payment information
    await page.fill('input[name="cardNumber"]', '4242424242424242');
    await page.fill('input[name="cardExpiry"]', '12/25');
    await page.fill('input[name="cardCvv"]', '123');
    
    // Place order
    await page.click('button:has-text("Place Order")');
    
    // Verify order confirmation
    await expect(page).toHaveURL(/.*\/order-confirmation/);
    await expect(page.locator('.confirmation-message')).toContainText('Order placed successfully');
    await expect(page.locator('.order-number')).toBeVisible();
    
    // Verify cart is empty
    await page.click('.cart-icon');
    await expect(page.locator('.empty-cart-message')).toContainText('Your cart is empty');
  });
});

E2E Testing with Selenium

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
# Python - E2E Tests with Selenium
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
import unittest

class TestUserAuthentication(unittest.TestCase):
    def setUp(self):
        self.driver = webdriver.Chrome()
        self.driver.implicitly_wait(10)
        self.base_url = "http://localhost:3000"
    
    def tearDown(self):
        self.driver.quit()
    
    def test_successful_login(self):
        driver = self.driver
        
        # Navigate to login page
        driver.get(f"{self.base_url}/login")
        
        # Enter credentials
        email_input = driver.find_element(By.NAME, "email")
        password_input = driver.find_element(By.NAME, "password")
        
        email_input.send_keys("testuser@example.com")
        password_input.send_keys("password123")
        
        # Submit form
        submit_button = driver.find_element(By.CSS_SELECTOR, "button[type='submit']")
        submit_button.click()
        
        # Wait for redirect to dashboard
        WebDriverWait(driver, 10).until(
            EC.url_contains("/dashboard")
        )
        
        # Verify user is logged in
        welcome_message = driver.find_element(By.CSS_SELECTOR, ".welcome-message")
        self.assertIn("Welcome", welcome_message.text)
        
        # Verify logout button is visible
        logout_button = driver.find_element(By.CSS_SELECTOR, ".logout-btn")
        self.assertTrue(logout_button.is_displayed())
    
    def test_login_with_invalid_credentials(self):
        driver = self.driver
        
        driver.get(f"{self.base_url}/login")
        
        email_input = driver.find_element(By.NAME, "email")
        password_input = driver.find_element(By.NAME, "password")
        
        email_input.send_keys("invalid@example.com")
        password_input.send_keys("wrongpassword")
        
        submit_button = driver.find_element(By.CSS_SELECTOR, "button[type='submit']")
        submit_button.click()
        
        # Wait for error message
        error_message = WebDriverWait(driver, 10).until(
            EC.presence_of_element_located((By.CSS_SELECTOR, ".error-message"))
        )
        
        self.assertIn("Invalid credentials", error_message.text)
        
        # Verify still on login page
        self.assertIn("/login", driver.current_url)
    
    def test_logout(self):
        driver = self.driver
        
        # Login first
        self._login(driver, "testuser@example.com", "password123")
        
        # Click logout button
        logout_button = driver.find_element(By.CSS_SELECTOR, ".logout-btn")
        logout_button.click()
        
        # Wait for redirect to home page
        WebDriverWait(driver, 10).until(
            EC.url_contains("/")
        )
        
        # Verify login button is visible (user is logged out)
        login_button = driver.find_element(By.LINK_TEXT, "Login")
        self.assertTrue(login_button.is_displayed())
    
    def _login(self, driver, email, password):
        driver.get(f"{self.base_url}/login")
        driver.find_element(By.NAME, "email").send_keys(email)
        driver.find_element(By.NAME, "password").send_keys(password)
        driver.find_element(By.CSS_SELECTOR, "button[type='submit']").click()
        WebDriverWait(driver, 10).until(EC.url_contains("/dashboard"))

if __name__ == "__main__":
    unittest.main()

Test-Driven Development (TDD)

The TDD Cycle

TDD follows the Red-Green-Refactor cycle:

  1. Red: Write a failing test
  2. Green: Write minimal code to make it pass
  3. Refactor: Improve the code while keeping tests green

TDD Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
# Python - TDD Example: Building a Password Validator

# Step 1: Write the first test (RED)
class TestPasswordValidator(unittest.TestCase):
    def test_password_must_be_at_least_8_characters(self):
        validator = PasswordValidator()
        result = validator.validate("short")
        self.assertFalse(result.is_valid)
        self.assertIn("at least 8 characters", result.errors[0])

# Step 2: Write minimal code to pass (GREEN)
class PasswordValidator:
    def validate(self, password):
        errors = []
        if len(password) < 8:
            errors.append("Password must be at least 8 characters")
        return ValidationResult(len(errors) == 0, errors)

# Step 3: Add more tests (RED)
class TestPasswordValidator(unittest.TestCase):
    # ... previous test ...
    
    def test_password_must_contain_uppercase_letter(self):
        validator = PasswordValidator()
        result = validator.validate("lowercase123")
        self.assertFalse(result.is_valid)
        self.assertIn("uppercase letter", result.errors[0])

# Step 4: Implement feature (GREEN)
class PasswordValidator:
    def validate(self, password):
        errors = []
        
        if len(password) < 8:
            errors.append("Password must be at least 8 characters")
        
        if not any(c.isupper() for c in password):
            errors.append("Password must contain at least one uppercase letter")
        
        return ValidationResult(len(errors) == 0, errors)

# Step 5: Continue adding tests and implementing features
class TestPasswordValidator(unittest.TestCase):
    # ... previous tests ...
    
    def test_password_must_contain_lowercase_letter(self):
        validator = PasswordValidator()
        result = validator.validate("UPPERCASE123")
        self.assertFalse(result.is_valid)
    
    def test_password_must_contain_digit(self):
        validator = PasswordValidator()
        result = validator.validate("NoDigitsHere")
        self.assertFalse(result.is_valid)
    
    def test_password_must_contain_special_character(self):
        validator = PasswordValidator()
        result = validator.validate("NoSpecial123")
        self.assertFalse(result.is_valid)
    
    def test_valid_password_passes_all_checks(self):
        validator = PasswordValidator()
        result = validator.validate("Valid123!")
        self.assertTrue(result.is_valid)
        self.assertEqual(len(result.errors), 0)

# Final implementation
class PasswordValidator:
    def validate(self, password):
        errors = []
        
        if len(password) < 8:
            errors.append("Password must be at least 8 characters")
        
        if not any(c.isupper() for c in password):
            errors.append("Password must contain at least one uppercase letter")
        
        if not any(c.islower() for c in password):
            errors.append("Password must contain at least one lowercase letter")
        
        if not any(c.isdigit() for c in password):
            errors.append("Password must contain at least one digit")
        
        if not any(c in "!@#$%^&*()_+-=[]{}|;:,.<>?" for c in password):
            errors.append("Password must contain at least one special character")
        
        return ValidationResult(len(errors) == 0, errors)

Benefits of TDD

  1. Better Design: Forces you to think about interfaces before implementation
  2. Living Documentation: Tests serve as examples of how to use the code
  3. Confidence: Comprehensive test coverage from the start
  4. Less Debugging: Catch bugs immediately when they’re introduced
  5. Easier Refactoring: Tests provide a safety net for changes

Test Coverage

Understanding Coverage Metrics

Coverage metrics help identify untested code:

  • Line Coverage: Percentage of lines executed
  • Branch Coverage: Percentage of conditional branches tested
  • Function Coverage: Percentage of functions called
  • Statement Coverage: Percentage of statements executed

Measuring Coverage

1
2
3
4
5
6
7
8
# Python - Coverage with pytest
pip install pytest pytest-cov

# Run tests with coverage
pytest --cov=myapp --cov-report=html --cov-report=term

# View detailed coverage report
open htmlcov/index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# JavaScript - Coverage with Jest
# jest.config.js
module.exports = {
  collectCoverage: true,
  coverageDirectory: 'coverage',
  coverageReporters: ['html', 'text', 'lcov'],
  collectCoverageFrom: [
    'src/**/*.js',
    '!src/**/*.test.js'
  ],
  coverageThresholds: {
    global: {
      branches: 80,
      functions: 80,
      lines: 80,
      statements: 80
    }
  }
};

# Run tests with coverage
npm test -- --coverage
1
2
3
4
5
6
7
8
# C# - Coverage with coverlet
dotnet add package coverlet.collector

# Run tests with coverage
dotnet test /p:CollectCoverage=true /p:CoverageReportFormat=html

# View report
open coverage/index.html

Best Practices

General Testing Best Practices

  1. Test Behavior, Not Implementation
1
2
3
4
5
6
7
8
9
10
11
12
// Bad - Testing implementation details
test('should call helper method', () => {
  const spy = jest.spyOn(calculator, '_helperMethod');
  calculator.add(2, 3);
  expect(spy).toHaveBeenCalled();
});

// Good - Testing behavior
test('should return sum of two numbers', () => {
  const result = calculator.add(2, 3);
  expect(result).toBe(5);
});
  1. Use Descriptive Test Names
1
2
3
4
5
6
7
# Bad
def test_user():
    pass

# Good
def test_create_user_with_valid_data_returns_user_object():
    pass
  1. Keep Tests Independent
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Bad - Tests depend on each other
let userId;

test('create user', () => {
  userId = createUser({ name: 'Test' });
});

test('get user', () => {
  const user = getUser(userId); // Depends on previous test
});

// Good - Each test is independent
test('create user returns new user', () => {
  const user = createUser({ name: 'Test' });
  expect(user).toHaveProperty('id');
});

test('get user returns existing user', () => {
  const userId = createUser({ name: 'Test' });
  const user = getUser(userId);
  expect(user).toBeDefined();
});
  1. Use Test Fixtures and Factories
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Python - Using factories
class UserFactory:
    @staticmethod
    def create(**kwargs):
        defaults = {
            'username': 'testuser',
            'email': 'test@example.com',
            'first_name': 'Test',
            'last_name': 'User'
        }
        defaults.update(kwargs)
        return User(**defaults)

# Use in tests
def test_user_full_name():
    user = UserFactory.create(first_name='John', last_name='Doe')
    assert user.full_name() == 'John Doe'
  1. Test Edge Cases
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
describe('divide', () => {
  it('should handle normal division', () => {
    expect(divide(10, 2)).toBe(5);
  });
  
  it('should handle division by zero', () => {
    expect(() => divide(10, 0)).toThrow('Division by zero');
  });
  
  it('should handle negative numbers', () => {
    expect(divide(-10, 2)).toBe(-5);
  });
  
  it('should handle decimal results', () => {
    expect(divide(10, 3)).toBeCloseTo(3.33, 2);
  });
  
  it('should handle division of zero', () => {
    expect(divide(0, 5)).toBe(0);
  });
});

Performance Best Practices

  1. Parallelize Tests: Run tests in parallel when possible
  2. Use In-Memory Databases: For integration tests, use in-memory databases
  3. Minimize Test Setup: Only setup what’s needed for each test
  4. Cache Test Data: Reuse expensive test data setup across tests
  5. Avoid Sleeping: Use proper waiting mechanisms instead of arbitrary delays

Maintenance Best Practices

  1. DRY in Tests: Extract common setup into helper functions
  2. Clear Test Data: Clean up after tests to prevent interference
  3. Version Test Dependencies: Keep test dependencies up to date
  4. Document Complex Tests: Add comments for non-obvious test logic
  5. Review Test Code: Apply same code review standards to tests

Conclusion

A comprehensive testing strategy that combines unit tests, integration tests, and end-to-end tests provides the best balance of speed, reliability, and confidence. By following the testing pyramid and best practices outlined in this guide, you can build a robust test suite that catches bugs early and enables confident refactoring.

Key takeaways:

  • Write many fast unit tests that test individual components in isolation
  • Use integration tests to verify that components work together correctly
  • Add a few critical E2E tests for important user workflows
  • Use test doubles (mocks, stubs, fakes) to isolate code under test
  • Consider TDD to drive better design and comprehensive coverage
  • Measure coverage but don’t obsess over 100 percent
  • Keep tests maintainable, independent, and focused on behavior

Remember that testing is an investment in code quality and team velocity. Well-tested code is easier to change, debug, and maintain over time.

References

  • Martin Fowler - Test Pyramid: https://martinfowler.com/articles/practical-test-pyramid.html
  • Kent Beck - Test-Driven Development by Example
  • Gerard Meszaros - xUnit Test Patterns
  • Python unittest documentation: https://docs.python.org/3/library/unittest.html
  • Jest documentation: https://jestjs.io/
  • Playwright documentation: https://playwright.dev/
  • xUnit documentation: https://xunit.net/
  • Selenium documentation: https://www.selenium.dev/documentation/
This post is licensed under CC BY 4.0 by the author.