Skip to main content
This guide covers the testing strategy for the microservices-app project, including unit tests, integration tests, and smoke tests.

Testing Overview

The project uses multiple testing approaches:
  • Go Unit Tests: Test individual Go service components with go test
  • Go Integration Tests: Test services with real dependencies (database, gRPC clients)
  • Node.js Unit Tests: Test Node.js services with Vitest and supertest
  • Smoke Tests: End-to-end tests that verify all services are working together
  • Frontend Build: TypeScript type checking ensures type safety

Go Service Testing

Running Go Tests

Run all Go tests:
cd services
go test ./...
Run tests with coverage:
go test -cover ./...
Run tests with verbose output:
go test -v ./...
Run tests for a specific package:
go test ./internal/greeter/...

Writing Unit Tests

Unit tests use mocks to isolate the service under test. Example: services/internal/greeter/service_test.go
package greeter

import (
    "context"
    "testing"
    "time"

    "connectrpc.com/connect"
    callerv1 "github.com/hackz-megalo-cup/microservices-app/services/gen/go/caller/v1"
    greeterv1 "github.com/hackz-megalo-cup/microservices-app/services/gen/go/greeter/v1"
)

// Mock implementation of CallerServiceClient
type mockCallerClient struct {
    resp *connect.Response[callerv1.CallExternalResponse]
    err  error
}

func (m *mockCallerClient) CallExternal(
    _ context.Context,
    _ *connect.Request[callerv1.CallExternalRequest],
) (*connect.Response[callerv1.CallExternalResponse], error) {
    return m.resp, m.err
}

func TestGreet_Normal(t *testing.T) {
    // Arrange: Create mock with expected response
    mock := &mockCallerClient{
        resp: connect.NewResponse(&callerv1.CallExternalResponse{
            StatusCode: 200,
            BodyLength: 42,
        }),
    }
    svc := NewService(mock, "http://example.com", 5*time.Second, nil)

    // Act: Call the service method
    resp, err := svc.Greet(
        context.Background(),
        connect.NewRequest(&greeterv1.GreetRequest{Name: "Alice"}),
    )

    // Assert: Verify the response
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if resp.Msg.GetMessage() != "Hello Alice from greeter-service!" {
        t.Errorf("got message %q, want %q",
            resp.Msg.GetMessage(),
            "Hello Alice from greeter-service!",
        )
    }
}

Writing Integration Tests

Integration tests verify services work with real dependencies. Example: services/internal/greeter/service_integration_test.go
//go:build integration

package greeter

import (
    "context"
    "testing"
    "time"

    "connectrpc.com/connect"
    greeterv1 "github.com/hackz-megalo-cup/microservices-app/services/gen/go/greeter/v1"
)

func TestGreet_Integration(t *testing.T) {
    if testing.Short() {
        t.Skip("skipping integration test")
    }

    // Use real dependencies
    svc := NewService(
        realCallerClient,
        "http://real-endpoint.com",
        5*time.Second,
        nil,
    )

    resp, err := svc.Greet(
        context.Background(),
        connect.NewRequest(&greeterv1.GreetRequest{Name: "Integration"}),
    )

    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if resp.Msg.GetMessage() == "" {
        t.Error("expected non-empty message")
    }
}
Run integration tests:
go test -tags=integration ./...

Test Best Practices

Use Table-Driven Tests

tests := []struct {
    name    string
    input   string
    want    string
    wantErr bool
}{
    {"empty name", "", "Hello World", false},
    {"valid name", "Alice", "Hello Alice", false},
}
for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {
        // test logic
    })
}

Mock External Dependencies

type mockClient struct {
    response *Response
    err      error
}

func (m *mockClient) Call(...) (..., error) {
    return m.response, m.err
}

Test Error Cases

func TestGreet_Error(t *testing.T) {
    mock := &mockClient{
        err: errors.New("connection failed"),
    }
    svc := NewService(mock)
    _, err := svc.Greet(...)
    if err == nil {
        t.Fatal("expected error, got nil")
    }
}

Use Context Timeouts

ctx, cancel := context.WithTimeout(
    context.Background(),
    2*time.Second,
)
defer cancel()

resp, err := svc.Call(ctx, req)

Node.js Service Testing

Running Node.js Tests

Run tests for a specific service:
cd node-services/auth-service
npm test
Run tests in watch mode:
npm run test:watch
Run tests with coverage:
npm test -- --coverage

Writing Node.js Tests

Node.js services use Vitest and supertest for HTTP testing. Example: node-services/auth-service/__tests__/auth.test.js
import request from 'supertest';
import { describe, expect, it } from 'vitest';
import app from '../app.js';

describe('POST /auth/login', () => {
  it('returns 400 when email is missing', async () => {
    const res = await request(app)
      .post('/auth/login')
      .send({ password: 'secret' });
    
    expect(res.status).toBe(400);
    expect(res.body.error).toBe('email and password are required');
  });

  it('returns 200 with valid credentials', async () => {
    const res = await request(app)
      .post('/auth/login')
      .send({ 
        email: '[email protected]',
        password: 'password123'
      });
    
    expect(res.status).toBe(200);
    expect(res.body).toHaveProperty('token');
    expect(typeof res.body.token).toBe('string');
  });
});

describe('GET /verify', () => {
  it('returns 401 without authorization header', async () => {
    const res = await request(app).get('/verify');
    expect(res.status).toBe(401);
    expect(res.body.error).toBe('missing bearer token');
  });

  it('returns 200 with valid token', async () => {
    // First login to get token
    const loginRes = await request(app)
      .post('/auth/login')
      .send({ email: '[email protected]', password: 'password' });
    
    const { token } = loginRes.body;

    // Then verify
    const verifyRes = await request(app)
      .get('/verify')
      .set('Authorization', `Bearer ${token}`);
    
    expect(verifyRes.status).toBe(200);
    expect(verifyRes.body.ok).toBe(true);
  });
});

Test Configuration

Add test configuration to package.json:
{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage"
  },
  "devDependencies": {
    "vitest": "^2.0.0",
    "supertest": "^7.0.0",
    "@vitest/coverage-v8": "^2.0.0"
  }
}
Create vitest.config.js:
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    globals: true,
    environment: 'node',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
    },
  },
});

Smoke Tests

Smoke tests verify that all services are deployed and functioning correctly in a Kubernetes environment.

Running Smoke Tests

Smoke tests require services to be running in Kubernetes:
# Start Tilt first
tilt up

# Run smoke tests
test-smoke

What Smoke Tests Verify

The smoke test script (scripts/smoke-test.sh) checks:
  1. Deployment Health: All deployments are ready
    kubectl -n microservices rollout status deploy/greeter-service
    kubectl -n microservices rollout status deploy/auth-service
    # ... etc
    
  2. HTTP Health Endpoints: Services respond to health checks
    curl -fsS http://127.0.0.1:18080/healthz
    curl -fsS http://127.0.0.1:13000/healthz
    
  3. gRPC Functionality: gRPC services accept and respond to calls
    grpcurl -plaintext -d '{"name":"Smoke"}' \
      127.0.0.1:18080 greeter.v1.GreeterService/Greet
    
  4. Authentication Flow: Auth service can issue and verify tokens
    TOKEN=$(curl -X POST http://127.0.0.1:18090/auth/login \
      -H 'content-type: application/json' \
      -d '{"email":"[email protected]","password":"password"}')
    curl http://127.0.0.1:18090/verify -H "authorization: Bearer $TOKEN"
    

Writing Custom Smoke Tests

Add checks to scripts/smoke-test.sh:
echo "==> Testing new service"
grpcurl -plaintext -d '{"input":"test"}' \
  127.0.0.1:18085 myservice.v1.MyServiceService/MyRPC

echo "==> Testing webhook endpoint"
curl -fsS -X POST http://127.0.0.1:13001/webhook \
  -H "Content-Type: application/json" \
  -d '{"payload":"test"}'

Frontend Testing

While the frontend doesn’t have traditional unit tests, it has type checking via TypeScript:
cd frontend
npm run build  # Runs TypeScript type checking + Vite build
Type errors will fail the build:
// This will fail type checking
const result: string = 42;  // Type 'number' is not assignable to type 'string'

CI/CD Testing

Tests run automatically in GitHub Actions:
1

Go Tests

- name: Run Go tests
  run: cd services && go test ./...
2

Node.js Tests

- name: Run Node.js tests
  run: |
    cd node-services/auth-service && npm test
    cd node-services/custom-lang-service && npm test
3

Frontend Type Check

- name: Build frontend
  run: cd frontend && npm run build
4

Contract Tests

- name: Buf lint and breaking
  run: |
    buf lint
    buf breaking --against '.git#branch=main'

Pre-commit Testing

Tests run automatically on commit via git hooks:
  • go test ./... - Runs before committing Go files
  • golangci-lint run - Lints Go code
  • biome check - Lints TypeScript code
To skip hooks temporarily (not recommended):
git commit --no-verify -m "message"

Common Testing Commands

# All tests
cd services && go test ./...

# With coverage
go test -cover ./...

# Specific package
go test ./internal/greeter/...

# Integration tests
go test -tags=integration ./...

See Also

Build docs developers (and LLMs) love