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: "[email protected]"}
// Test save
err := repo.Save(user)
assert.NoError(t, err)
// Verify in database
saved, err := repo.FindByEmail("[email protected]")
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
-
Service Dependencies
- ต้อง mock หลาย services
- Integration testing ซับซ้อน
-
Data Consistency
- แต่ละ service มี database ตัวเอง
- ยากต่อการ setup test data
-
E2E Testing
- ต้องรัน หลาย services พร้อมกัน
- ช้าและแพง
-
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 emailTest 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: "[email protected]"},
{ID: 2, Name: "Jane", Email: "[email protected]"},
}Builders: Create test data programmatically
user := NewUserBuilder().
WithName("John").
WithEmail("[email protected]").
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: