Testing Strategies

Back to Software Development Index

The Testing Pyramid

การทดสอบซอฟต์แวร์ควรมีการกระจายตัวตาม Testing Pyramid:

        ╱╲
       ╱E2E╲          ← UI Tests (Fewest)
      ╱──────╲           Slow, Brittle, Expensive
     ╱────────╲
    ╱Component╲       ← Service/API Tests
   ╱────────────╲        Medium speed & cost
  ╱──────────────╲
 ╱  Integration  ╲    ← Integration Tests
╱──────────────────╲     Test component interaction
───────────────────
    Unit Tests        ← Unit Tests (Most)
                        Fast, Stable, Cheap

Principle

  • Base (Unit Tests): มากที่สุด เร็วที่สุด ถูกที่สุด
  • Middle (Integration): ปานกลาง
  • Top (E2E): น้อยที่สุด ช้าที่สุด แพงที่สุด

70% Unit, 20% Integration, 10% E2E (guideline)

Test Types

1. Unit Tests

ทดสอบ function/method แต่ละตัวแยกกัน

Characteristics:

  • ทดสอบ 1 unit of work (function, method, class)
  • Fast - รันใน milliseconds
  • Isolated - ไม่พึ่งพา database, network, file system
  • Deterministic - ผลลัพธ์เหมือนเดิมทุกครั้ง

Example (Go):

func Add(a, b int) int {
    return a + b
}
 
func TestAdd(t *testing.T) {
    result := Add(2, 3)
    if result != 5 {
        t.Errorf("Expected 5, got %d", result)
    }
}

When to Use:

  • Test business logic
  • Test edge cases
  • Test error handling
  • Fast feedback during development

Best Practices:

  • One assertion per test
  • Clear test names
  • Arrange-Act-Assert pattern
  • Test edge cases and errors

2. Integration Tests

ทดสอบการทำงานร่วมกันของหลาย components

Characteristics:

  • ทดสอบการ integrate กับ external systems
  • ใช้ real database, APIs, file systems
  • Slower than unit tests
  • Test actual behavior

Example:

func TestUserRepository_Save(t *testing.T) {
    // Setup test database
    db := setupTestDB()
    defer db.Close()
    
    repo := NewUserRepository(db)
    user := User{Name: "John", Email: "john@example.com"}
    
    // Test save
    err := repo.Save(user)
    assert.NoError(t, err)
    
    // Verify in database
    saved, err := repo.FindByEmail("john@example.com")
    assert.NoError(t, err)
    assert.Equal(t, "John", saved.Name)
}

When to Use:

  • Test database queries
  • Test API integration
  • Test message queue integration
  • Test file I/O operations

Best Practices:

  • Use test databases
  • Clean up after tests
  • Use transactions for isolation
  • Mock external services when needed

3. Component Tests

ทดสอบ service แบบ end-to-end (ทั้ง service)

Characteristics:

  • ทดสอบ entire service แต่แยกออกจากระบบอื่น
  • Mock external dependencies
  • Test through public API
  • In-process testing

Example:

func TestOrderAPI_CreateOrder(t *testing.T) {
    // Start test server
    server := startTestServer()
    defer server.Close()
    
    // Mock dependencies
    mockPayment := NewMockPaymentService()
    mockInventory := NewMockInventoryService()
    
    // Create order
    order := OrderRequest{
        Items: []Item{{ProductID: 1, Quantity: 2}},
    }
    
    resp, err := http.Post(
        server.URL+"/orders",
        "application/json",
        toJSON(order),
    )
    
    assert.NoError(t, err)
    assert.Equal(t, 201, resp.StatusCode)
}

When to Use:

  • Test service behavior
  • Test API contracts
  • Test business workflows
  • Before E2E tests

4. End-to-End (E2E) Tests

ทดสอบระบบทั้งหมด จาก UI ถึง database

Characteristics:

  • ทดสอบ entire system รวมทุก services
  • Test through UI or main entry point
  • Slowest and most expensive
  • Test user scenarios

Example (Selenium):

describe('Login Flow', () => {
    it('should allow user to login', async () => {
        await browser.url('/login');
        
        await $('#username').setValue('testuser');
        await $('#password').setValue('password123');
        await $('#loginBtn').click();
        
        const welcome = await $('#welcome').getText();
        expect(welcome).toContain('Welcome, testuser');
    });
});

When to Use:

  • Test critical user journeys
  • Test full integration
  • Smoke tests in production
  • UAT (User Acceptance Testing)

Best Practices:

  • Test critical paths only
  • Keep tests independent
  • Use page object pattern
  • Run in CI/CD pipeline
  • Parallelize when possible

5. Contract Tests

ทดสอบ API contracts ระหว่าง services

Characteristics:

  • ทดสอบ provider/consumer agreement
  • Ensure API compatibility
  • Prevent breaking changes
  • Consumer-driven

Example (Pact):

// Consumer test
describe('User Service', () => {
    it('should get user by id', async () => {
        await provider.addInteraction({
            state: 'user exists',
            uponReceiving: 'a request for user',
            withRequest: {
                method: 'GET',
                path: '/users/1',
            },
            willRespondWith: {
                status: 200,
                body: {
                    id: 1,
                    name: 'John',
                },
            },
        });
        
        const user = await userService.getUser(1);
        expect(user.name).toBe('John');
    });
});

When to Use:

  • Microservices architecture
  • Multiple teams working on different services
  • API versioning
  • Breaking change detection

Testing in Microservices

Challenges

  1. Service Dependencies

    • ต้อง mock หลาย services
    • Integration testing ซับซ้อน
  2. Data Consistency

    • แต่ละ service มี database ตัวเอง
    • ยากต่อการ setup test data
  3. E2E Testing

    • ต้องรัน หลาย services พร้อมกัน
    • ช้าและแพง
  4. Flaky Tests

    • Network issues
    • Timing problems
    • Eventual consistency

Solutions

1. Consumer-Driven Contract Testing

Service A (Consumer)
    ↓ Contract
Service B (Provider)

แทนที่จะ test integration ทั้งหมด
ใช้ contract test แทน

Benefits:

  • Faster than E2E
  • Independent testing
  • Prevent breaking changes

2. Test Doubles

Mock: ตัวปลอมที่ verify interactions

mockPayment := new(MockPaymentService)
mockPayment.On("Process", amount).Return(nil)

Stub: ตัวปลอมที่ return ค่าที่กำหนด

stubDB := new(StubDatabase)
stubDB.Users = []User{{ID: 1, Name: "John"}}

Fake: Implementation จริงแต่เบา (เช่น in-memory DB)

fakeDB := NewInMemoryDB()

3. Test Containers

ใช้ Docker containers สำหรับ dependencies:

func TestWithPostgres(t *testing.T) {
    ctx := context.Background()
    
    // Start PostgreSQL container
    postgres, err := testcontainers.GenericContainer(ctx,
        testcontainers.GenericContainerRequest{
            ContainerRequest: testcontainers.ContainerRequest{
                Image: "postgres:14",
                Env: map[string]string{
                    "POSTGRES_PASSWORD": "password",
                },
                ExposedPorts: []string{"5432/tcp"},
            },
            Started: true,
        })
    
    defer postgres.Terminate(ctx)
    
    // Run tests...
}

Benefits:

  • Real dependencies
  • Isolated
  • Consistent environment
  • Fast setup/teardown

4. Service Virtualization

สร้าง virtual service เพื่อ simulate external APIs:

Tools: WireMock, Mountebank, Hoverfly

// Setup WireMock stub
wiremock.StubFor(
    Get("/api/users/1").
    WillReturn(
        Status(200).
        Body(`{"id": 1, "name": "John"}`),
    ),
)

Testing Strategy for Microservices

1. Unit Tests (70%)
   ├─ Business logic
   ├─ Domain models
   └─ Utilities

2. Integration Tests (20%)
   ├─ Database queries
   ├─ Message queue
   └─ Internal APIs

3. Contract Tests (5%)
   ├─ API contracts
   └─ Event schemas

4. Component Tests (3%)
   ├─ Service behavior
   └─ API endpoints

5. E2E Tests (2%)
   ├─ Critical user journeys
   └─ Happy paths only

Best Practices

Test Organization

Arrange-Act-Assert (AAA):

func TestUserService_CreateUser(t *testing.T) {
    // Arrange
    service := NewUserService()
    user := User{Name: "John"}
    
    // Act
    err := service.Create(user)
    
    // Assert
    assert.NoError(t, err)
}

Given-When-Then (BDD):

Scenario: User creates order
  Given user is logged in
  And user has items in cart
  When user clicks checkout
  Then order is created
  And user receives confirmation email

Test Naming

Convention: Test_<MethodName>_<Scenario>_<ExpectedResult>

func TestUserService_CreateUser_ValidData_ReturnsSuccess(t *testing.T)
func TestUserService_CreateUser_DuplicateEmail_ReturnsError(t *testing.T)
func TestOrderService_ProcessOrder_InsufficientStock_ReturnsError(t *testing.T)

Test Data Management

Fixtures: Pre-defined test data

var testUsers = []User{
    {ID: 1, Name: "John", Email: "john@example.com"},
    {ID: 2, Name: "Jane", Email: "jane@example.com"},
}

Builders: Create test data programmatically

user := NewUserBuilder().
    WithName("John").
    WithEmail("john@example.com").
    Build()

Factories: Generate test data

user := UserFactory.Create()

Test Coverage

Aim for meaningful coverage, not 100%:

  • Critical business logic: 80-100%
  • Infrastructure code: 50-70%
  • UI code: 30-50%

Tools:

  • Go: go test -cover
  • JavaScript: Jest, Istanbul
  • Java: JaCoCo

Related:

References: