Module 9: Common Team Workflows: GitFlow and Trunk-Based Development
Implement professional Git workflows used by testing teams worldwide. Compare GitFlow (feature, develop, release, hotfix branches) with Trunk-Based Development for test projects. Learn when each workflow suits different team sizes and release cycles, with practical setup for test automation teams.
Multi-Repository Workflow: Managing Tests Across Microservices
Why This Matters
In modern software development, applications are increasingly built as distributed systems with multiple microservicesβeach with its own repository. As a test automation engineer, you face a unique challenge: how do you manage test code that spans across 5, 10, or even 50 different service repositories while maintaining consistency, versioning, and team collaboration?
The Real-World Problem:
Imagine your team maintains automated tests for an e-commerce platform with separate repositories for payment processing, inventory management, order fulfillment, and customer notifications. A new feature requires changes across three services simultaneously. How do you:
- Keep test branches synchronized across repositories?
- Ensure compatible test versions are deployed together?
- Manage hotfixes that need immediate testing across multiple services?
- Coordinate releases when different services move at different speeds?
Common Pain Points This Lesson Addresses:
- Version Chaos: Tests in Repository A work with Service B version 2.1, but Service C is still on version 1.8βwhich test version should run in CI?
- Workflow Confusion: Your team can’t agree whether to use long-lived release branches or commit directly to main
- Integration Nightmares: Merging test updates becomes a bottleneck because your branching strategy doesn’t match your release cadence
- Knowledge Gaps: Team members follow different branching conventions, creating inconsistent Git histories
When You’ll Use These Skills:
- Working in organizations with microservices or distributed architectures
- Managing test automation for products with multiple release trains
- Coordinating testing efforts across multiple teams and repositories
- Establishing testing standards for rapidly growing engineering organizations
- Migrating from monolithic to microservices testing approaches
What You’ll Accomplish
By the end of this lesson, you’ll have hands-on experience with professional multi-repository workflows used by enterprise testing teams. You won’t just learn theoryβyou’ll implement actual branching strategies and multi-repo coordination techniques.
Here’s How We’ll Cover Each Objective:
1. Compare GitFlow vs Trunk-Based Development You’ll analyze both workflows side-by-side, examining real decision trees that help you choose the right approach based on team size (2 developers vs 50), release frequency (daily vs quarterly), and testing requirements.
2. Implement GitFlow Branching Strategy You’ll create a complete GitFlow structure for a test repository, establishing feature, develop, release, and hotfix branches. You’ll practice the full cycle from feature development through release preparation.
3. Configure Trunk-Based Development Workflow You’ll set up a trunk-based repository with branch protection rules, short-lived feature branches, and feature flags for testsβlearning when this leaner approach outperforms GitFlow.
4. Manage Tests Across Multiple Repositories You’ll work with a simulated microservices environment, using git submodules to coordinate shared test utilities and managing dependencies between service-specific test repositories.
5. Coordinate Test Versions and Dependencies You’ll implement strategies for version pinning, dependency matrices, and cross-repository tagging that ensure your tests remain compatible as services evolve independently.
6. Choose the Appropriate Workflow You’ll apply decision frameworks to three realistic scenarios, justifying workflow choices based on concrete factors like deployment frequency, team structure, and compliance requirements.
This is advanced material that builds directly on your existing Git knowledge. You’ll leave with workflow patterns you can implement immediately in your organizationβplus the judgment to adapt them to your specific context.
Core Content
Core Content: Multi-Repository Workflow: Managing Tests Across Microservices
Core Concepts Explained
Understanding Multi-Repository Architecture
In microservices architecture, each service typically maintains its own repository with dedicated test suites. This distributed approach presents unique challenges:
- Dependency Management: Services depend on each other’s APIs and contracts
- Test Coordination: Integration tests must span multiple repositories
- Version Synchronization: Ensuring compatible versions across services
- CI/CD Orchestration: Coordinating test execution across repositories
Key Strategies for Multi-Repo Test Management
1. Monorepo vs. Multi-Repo Trade-offs
Multi-Repo Advantages:
- Service autonomy and independent deployments
- Clear ownership boundaries
- Smaller, focused codebases
Multi-Repo Challenges:
- Cross-repository dependencies
- Duplicate tooling configurations
- Complex integration testing
2. Shared Test Infrastructure
Create a dedicated repository for shared testing utilities, custom commands, and contract definitions.
organization/
βββ service-user-api/
β βββ tests/
β βββ package.json
βββ service-payment-api/
β βββ tests/
β βββ package.json
βββ service-order-api/
β βββ tests/
β βββ package.json
βββ shared-test-utils/
βββ helpers/
βββ fixtures/
βββ contracts/
Practical Implementation
Setting Up Shared Test Utilities
Step 1: Create a Shared Test Utilities Package
# Initialize shared utilities repository
mkdir shared-test-utils
cd shared-test-utils
npm init -y
npm install --save-dev @playwright/test axios joi
Step 2: Build Reusable Test Components
// shared-test-utils/helpers/api-client.js
const axios = require('axios');
class MicroserviceClient {
constructor(baseURL, serviceName) {
this.client = axios.create({
baseURL,
timeout: 5000,
headers: {
'X-Service-Name': serviceName
}
});
}
async healthCheck() {
const response = await this.client.get('/health');
return response.data;
}
async authenticatedRequest(method, endpoint, token, data = null) {
return await this.client({
method,
url: endpoint,
headers: { Authorization: `Bearer ${token}` },
data
});
}
}
module.exports = { MicroserviceClient };
Step 3: Create Contract Definitions
// shared-test-utils/contracts/user-service.contract.js
const Joi = require('joi');
const userSchema = Joi.object({
id: Joi.string().uuid().required(),
email: Joi.string().email().required(),
firstName: Joi.string().required(),
lastName: Joi.string().required(),
createdAt: Joi.date().iso().required()
});
const validateUserResponse = (data) => {
const { error, value } = userSchema.validate(data);
if (error) {
throw new Error(`Contract violation: ${error.message}`);
}
return value;
};
module.exports = { userSchema, validateUserResponse };
Step 4: Publish as NPM Package (Private Registry)
// shared-test-utils/package.json
{
"name": "@yourorg/shared-test-utils",
"version": "1.2.0",
"main": "index.js",
"publishConfig": {
"registry": "https://your-private-registry.com"
}
}
# Publish to private npm registry
npm publish
Consuming Shared Utilities Across Services
In each service repository:
# service-user-api/
npm install @yourorg/shared-test-utils@^1.2.0
// service-user-api/tests/integration/user-creation.spec.js
const { test, expect } = require('@playwright/test');
const { MicroserviceClient } = require('@yourorg/shared-test-utils/helpers/api-client');
const { validateUserResponse } = require('@yourorg/shared-test-utils/contracts/user-service.contract');
test.describe('User Service Integration', () => {
let userClient;
test.beforeAll(() => {
userClient = new MicroserviceClient(
process.env.USER_SERVICE_URL,
'test-suite'
);
});
test('should create user and validate contract', async () => {
const response = await userClient.authenticatedRequest(
'POST',
'/users',
process.env.TEST_TOKEN,
{
email: 'test@example.com',
firstName: 'John',
lastName: 'Doe'
}
);
// Validate against shared contract
expect(() => validateUserResponse(response.data)).not.toThrow();
expect(response.data.email).toBe('test@example.com');
});
});
Cross-Service Integration Testing
// service-order-api/tests/integration/order-with-user.spec.js
const { test, expect } = require('@playwright/test');
const { MicroserviceClient } = require('@yourorg/shared-test-utils/helpers/api-client');
test.describe('Cross-Service Order Flow', () => {
let userClient, orderClient;
let testUser;
test.beforeAll(async () => {
// Initialize clients for multiple services
userClient = new MicroserviceClient(
process.env.USER_SERVICE_URL,
'order-integration-test'
);
orderClient = new MicroserviceClient(
process.env.ORDER_SERVICE_URL,
'order-integration-test'
);
// Create test user in user service
const userResponse = await userClient.authenticatedRequest(
'POST',
'/users',
process.env.TEST_TOKEN,
{ email: 'ordertest@example.com', firstName: 'Order', lastName: 'Test' }
);
testUser = userResponse.data;
});
test('should create order for existing user', async () => {
// Create order referencing user from another service
const orderResponse = await orderClient.authenticatedRequest(
'POST',
'/orders',
process.env.TEST_TOKEN,
{
userId: testUser.id,
items: [
{ productId: 'prod-123', quantity: 2 }
]
}
);
expect(orderResponse.data.userId).toBe(testUser.id);
expect(orderResponse.data.status).toBe('pending');
});
test.afterAll(async () => {
// Cleanup: delete test user
await userClient.authenticatedRequest(
'DELETE',
`/users/${testUser.id}`,
process.env.TEST_TOKEN
);
});
});
Orchestrating Tests with Docker Compose
# docker-compose.test.yml
version: '3.8'
services:
user-service:
build: ./service-user-api
environment:
- DATABASE_URL=postgresql://test:test@db:5432/users
depends_on:
- db
ports:
- "3001:3000"
order-service:
build: ./service-order-api
environment:
- DATABASE_URL=postgresql://test:test@db:5432/orders
- USER_SERVICE_URL=http://user-service:3000
depends_on:
- db
- user-service
ports:
- "3002:3000"
payment-service:
build: ./service-payment-api
environment:
- DATABASE_URL=postgresql://test:test@db:5432/payments
depends_on:
- db
ports:
- "3003:3000"
db:
image: postgres:14
environment:
- POSTGRES_USER=test
- POSTGRES_PASSWORD=test
ports:
- "5432:5432"
integration-tests:
build: ./integration-tests
environment:
- USER_SERVICE_URL=http://user-service:3000
- ORDER_SERVICE_URL=http://order-service:3000
- PAYMENT_SERVICE_URL=http://payment-service:3000
depends_on:
- user-service
- order-service
- payment-service
command: npm test
Running orchestrated tests:
# Start all services and run integration tests
docker-compose -f docker-compose.test.yml up --abort-on-container-exit
# Run tests for specific service
docker-compose -f docker-compose.test.yml run integration-tests npm test -- user-service
CI/CD Workflow for Multi-Repository
graph TD
A[Service Commit] --> B{Affected Services?}
B -->|User Service| C[Run User Tests]
B -->|Order Service| D[Run Order Tests]
B -->|Payment Service| E[Run Payment Tests]
C --> F[Trigger Integration Tests]
D --> F
E --> F
F --> G{All Tests Pass?}
G -->|Yes| H[Deploy to Staging]
G -->|No| I[Block Deployment]
H --> J[Run E2E Tests]
J -->|Pass| K[Deploy to Production]
GitHub Actions workflow for coordinated testing:
# .github/workflows/integration-tests.yml
name: Multi-Service Integration Tests
on:
workflow_dispatch:
inputs:
services:
description: 'Services to test (comma-separated)'
required: true
jobs:
integration-tests:
runs-on: ubuntu-latest
steps:
- name: Checkout Integration Tests
uses: actions/checkout@v3
with:
repository: org/integration-tests
path: integration-tests
- name: Checkout Service Repos
run: |
git clone https://github.com/org/service-user-api.git
git clone https://github.com/org/service-order-api.git
git clone https://github.com/org/service-payment-api.git
- name: Start Services with Docker Compose
run: |
docker-compose -f docker-compose.test.yml up -d
sleep 10 # Wait for services to be ready
- name: Run Integration Tests
run: |
cd integration-tests
npm ci
npm test
env:
USER_SERVICE_URL: http://localhost:3001
ORDER_SERVICE_URL: http://localhost:3002
PAYMENT_SERVICE_URL: http://localhost:3003
- name: Publish Test Results
if: always()
uses: actions/upload-artifact@v3
with:
name: test-results
path: integration-tests/test-results/
<!-- SCREENSHOT_NEEDED: BROWSER
URL: https://github.com/org/integration-tests/actions
Description: GitHub Actions workflow showing multi-service integration test run with status checks
Placement: after CI/CD workflow section -->
Version Compatibility Matrix
// integration-tests/config/compatibility-matrix.js
module.exports = {
compatibilityMatrix: {
'user-service': {
'1.0.0': { 'order-service': ['1.0.0', '1.1.0'] },
'1.1.0': { 'order-service': ['1.1.0', '1.2.0'] },
'2.0.0': { 'order-service': ['2.0.0'] }
}
},
validateCompatibility(serviceName, version, dependencies) {
const compatible = this.compatibilityMatrix[serviceName][version];
for (const [depName, depVersion] of Object.entries(dependencies)) {
if (!compatible[depName]?.includes(depVersion)) {
throw new Error(
`Incompatible versions: ${serviceName}@${version} ` +
`does not support ${depName}@${depVersion}`
);
}
}
return true;
}
};
Common Mistakes Section
1. Tight Coupling Between Test Suites
β Wrong:
// Order tests directly importing user service code
const { createUser } = require('../../service-user-api/src/users');
β Correct:
// Use API contracts and HTTP requests
const userResponse = await userClient.authenticatedRequest('POST', '/users', token, userData);
2. Ignoring Service Version Mismatches
Problem: Running tests against incompatible service versions causes false failures.
Solution: Always verify version compatibility before running integration tests:
test.beforeAll(async () => {
const userVersion = await userClient.healthCheck().then(h => h.version);
const orderVersion = await orderClient.healthCheck().then(h => h.version);
compatibilityMatrix.validateCompatibility('user-service', userVersion, {
'order-service': orderVersion
});
});
3. Poor Test Data Cleanup
Problem: Tests leave orphaned data across multiple services.
Solution: Implement coordinated cleanup:
test.afterEach(async ({ }, testInfo) => {
if (testInfo.status !== 'passed') return; // Keep data for debugging failures
// Cleanup in reverse dependency order
await orderClient.authenticatedRequest('DELETE', `/orders/${orderId}`, token);
await userClient.authenticatedRequest('DELETE', `/users/${userId}`, token);
});
4. Debugging Cross-Service Failures
Enable distributed tracing:
// Add correlation IDs to all requests
const correlationId = `test-${Date.now()}-${Math.random()}`;
await userClient.authenticatedRequest('POST', '/users', token, userData, {
headers: { 'X-Correlation-ID': correlationId }
});
await orderClient.authenticatedRequest('POST', '/orders', token, orderData, {
headers: { 'X-Correlation-ID': correlationId }
});
// All logs across services will share this correlation ID
5. Network Timing Issues
Problem: Services not ready when tests start.
Solution: Implement robust health checks:
async function waitForService(client, maxAttempts = 30) {
for (let i = 0; i < maxAttempts; i++) {
try {
await client.healthCheck();
return true;
} catch (error) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
throw new Error('Service failed to become healthy');
}
test.beforeAll(async () => {
await waitForService(userClient);
await waitForService(orderClient);
await waitForService(paymentClient);
});
Hands-On Practice
Multi-Repository Workflow: Managing Tests Across Microservices
π― Hands-On Exercise
Exercise: Implement a Cross-Repository Test Suite for a Microservices E-Commerce Platform
Scenario: You’re managing test automation for an e-commerce platform with three microservices (User Service, Product Service, Order Service), each in separate repositories. You need to create a unified testing strategy that validates both individual services and their integration.
Task
Build a multi-repository test framework that:
- Runs tests across three separate service repositories
- Manages shared test utilities and contracts
- Executes integration tests validating cross-service workflows
- Reports results in a consolidated dashboard
Step-by-Step Instructions
Step 1: Set Up Repository Structure
Create three service repositories and one shared test repository:
# Create directory structure
mkdir microservices-testing && cd microservices-testing
mkdir user-service product-service order-service shared-test-framework
# Initialize each as a git repository
for dir in user-service product-service order-service shared-test-framework; do
cd $dir && git init && cd ..
done
Step 2: Create Shared Test Framework
In shared-test-framework/
:
// package.json
{
"name": "shared-test-framework",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"publish:local": "npm link"
}
}
// api-client.js
class ServiceClient {
constructor(baseUrl) {
this.baseUrl = baseUrl;
}
async get(endpoint) {
const response = await fetch(`${this.baseUrl}${endpoint}`);
return response.json();
}
async post(endpoint, data) {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
return response.json();
}
}
module.exports = { ServiceClient };
// test-data-factory.js
class TestDataFactory {
static createUser(overrides = {}) {
return {
id: `user-${Date.now()}`,
email: `test${Date.now()}@example.com`,
name: 'Test User',
...overrides
};
}
static createProduct(overrides = {}) {
return {
id: `product-${Date.now()}`,
name: 'Test Product',
price: 99.99,
stock: 100,
...overrides
};
}
static createOrder(userId, productId, overrides = {}) {
return {
id: `order-${Date.now()}`,
userId,
productId,
quantity: 1,
status: 'pending',
...overrides
};
}
}
module.exports = { TestDataFactory };
// contract-validator.js
class ContractValidator {
static validateUserSchema(user) {
const required = ['id', 'email', 'name'];
return required.every(field => user.hasOwnProperty(field));
}
static validateProductSchema(product) {
const required = ['id', 'name', 'price', 'stock'];
return required.every(field => product.hasOwnProperty(field));
}
static validateOrderSchema(order) {
const required = ['id', 'userId', 'productId', 'quantity', 'status'];
return required.every(field => order.hasOwnProperty(field));
}
}
module.exports = { ContractValidator };
Step 3: Implement Service-Specific Tests
In user-service/tests/
:
// user.test.js
const { ServiceClient } = require('shared-test-framework/api-client');
const { TestDataFactory } = require('shared-test-framework/test-data-factory');
const { ContractValidator } = require('shared-test-framework/contract-validator');
describe('User Service', () => {
let client;
beforeAll(() => {
client = new ServiceClient(process.env.USER_SERVICE_URL || 'http://localhost:3001');
});
test('should create a new user with valid schema', async () => {
const userData = TestDataFactory.createUser();
const result = await client.post('/users', userData);
expect(ContractValidator.validateUserSchema(result)).toBe(true);
expect(result.email).toBe(userData.email);
});
test('should retrieve user by id', async () => {
const userData = TestDataFactory.createUser();
const created = await client.post('/users', userData);
const retrieved = await client.get(`/users/${created.id}`);
expect(retrieved.id).toBe(created.id);
});
});
Step 4: Create Integration Test Suite
In shared-test-framework/integration-tests/
:
// e2e-order-flow.test.js
const { ServiceClient } = require('../api-client');
const { TestDataFactory } = require('../test-data-factory');
describe('E2E Order Flow', () => {
let userClient, productClient, orderClient;
let testUser, testProduct;
beforeAll(async () => {
userClient = new ServiceClient(process.env.USER_SERVICE_URL);
productClient = new ServiceClient(process.env.PRODUCT_SERVICE_URL);
orderClient = new ServiceClient(process.env.ORDER_SERVICE_URL);
// Setup test data
testUser = await userClient.post('/users', TestDataFactory.createUser());
testProduct = await productClient.post('/products', TestDataFactory.createProduct());
});
test('should complete full order workflow', async () => {
// Create order
const orderData = TestDataFactory.createOrder(testUser.id, testProduct.id);
const order = await orderClient.post('/orders', orderData);
expect(order.status).toBe('pending');
// Verify user has order
const userOrders = await userClient.get(`/users/${testUser.id}/orders`);
expect(userOrders).toContainEqual(expect.objectContaining({ id: order.id }));
// Verify product stock decreased
const product = await productClient.get(`/products/${testProduct.id}`);
expect(product.stock).toBe(testProduct.stock - orderData.quantity);
// Complete order
const completed = await orderClient.post(`/orders/${order.id}/complete`);
expect(completed.status).toBe('completed');
});
});
Step 5: Configure Test Orchestration
Create test-orchestrator.js
:
// test-orchestrator.js
const { execSync } = require('child_process');
const fs = require('fs');
class TestOrchestrator {
constructor(config) {
this.services = config.services;
this.results = {};
}
async runAllTests() {
console.log('π Starting multi-repository test execution...\n');
// Run unit tests for each service
for (const service of this.services) {
await this.runServiceTests(service);
}
// Run integration tests
await this.runIntegrationTests();
// Generate consolidated report
this.generateReport();
}
async runServiceTests(service) {
console.log(`π¦ Testing ${service.name}...`);
try {
const output = execSync(`cd ${service.path} && npm test`, { encoding: 'utf-8' });
this.results[service.name] = { status: 'passed', output };
console.log(`β
${service.name} tests passed\n`);
} catch (error) {
this.results[service.name] = { status: 'failed', error: error.message };
console.log(`β ${service.name} tests failed\n`);
}
}
async runIntegrationTests() {
console.log('π Running integration tests...');
try {
const output = execSync('cd shared-test-framework && npm run test:integration', { encoding: 'utf-8' });
this.results['integration'] = { status: 'passed', output };
console.log('β
Integration tests passed\n');
} catch (error) {
this.results['integration'] = { status: 'failed', error: error.message };
console.log('β Integration tests failed\n');
}
}
generateReport() {
const report = {
timestamp: new Date().toISOString(),
summary: {
total: Object.keys(this.results).length,
passed: Object.values(this.results).filter(r => r.status === 'passed').length,
failed: Object.values(this.results).filter(r => r.status === 'failed').length
},
details: this.results
};
fs.writeFileSync('test-report.json', JSON.stringify(report, null, 2));
console.log('π Test report generated: test-report.json');
}
}
// Usage
const config = {
services: [
{ name: 'user-service', path: './user-service' },
{ name: 'product-service', path: './product-service' },
{ name: 'order-service', path: './order-service' }
]
};
const orchestrator = new TestOrchestrator(config);
orchestrator.runAllTests();
Expected Outcome
After completing this exercise, you should have:
β
A shared test framework package usable across all services
β
Independent test suites for each microservice
β
Integration tests validating cross-service workflows
β
A test orchestration script that runs all tests and generates unified reports
β
Contract validation ensuring service compatibility
Solution Verification
Run the complete test suite:
node test-orchestrator.js
Check the generated test-report.json
for consolidated results showing all services tested successfully.
π Key Takeaways
-
Shared Test Infrastructure: Creating a common test framework (utilities, data factories, contract validators) reduces duplication and ensures consistency across microservices while maintaining repository independence.
-
Contract-Based Testing: Validating API contracts between services prevents integration failures and enables teams to work independently while ensuring compatibility at integration points.
-
Test Orchestration Strategy: Centralized test orchestration allows you to run distributed tests across multiple repositories while maintaining a unified view of system health through consolidated reporting.
-
Balance Independence and Integration: Service-specific tests validate individual functionality, while cross-repository integration tests ensure the system works as a wholeβboth are essential for microservices reliability.
-
Versioning and Dependencies: Managing shared test frameworks as versioned packages (npm/pip) allows controlled updates across services without forcing simultaneous changes to all repositories.
π Next Steps
Practice These Skills
- Add CI/CD Integration: Configure GitHub Actions/Jenkins to trigger cross-repository tests on any service update
- Implement Test Data Management: Create a shared test database seeding strategy for consistent integration test environments
- Add Contract Testing Tools: Integrate Pact or Spring Cloud Contract for formal contract testing
- Build Test Dependency Graphs: Map which integration tests are affected by changes to specific services
Related Topics to Explore
- Service Mesh Testing: Learn to test with Istio or Linkerd in your test environment
- Chaos Engineering: Implement failure injection across services to validate resilience
- Performance Testing at Scale: Use distributed load testing across microservices
- Test Environment Management: Explore tools like Testcontainers or Docker Compose for ephemeral test environments
- Distributed Tracing: Integrate OpenTelemetry for debugging failed cross-service tests
Recommended Resources
- Martin Fowler’s “Testing Strategies in a Microservice Architecture”
- Book: “Testing Microservices with Mountebank” by Brandon By