Testing a Go exchange API client: mocks, httptest and testcontainers

The Go service runs locally, tests pass. Then you remember the tests are hitting the real Binance API. The next day, CI fails: rate limit exceeded, unstable network, or the exchange quietly renamed a field in its response. Tests that depend on external APIs are flaky at best, unusable in CI at worst.

The problem is structural: exchanges don't have usable sandbox environments (Binance testnet exists, but with constraints that make automated integration testing difficult), rate limits are aggressive, and some operations like PlaceOrder have real side effects. A different approach is needed.

The problem with external APIs in tests

Three concrete reasons you can't test against real exchange APIs in CI:

  • Rate limits: Binance caps at 1200 requests/minute on the order book endpoint. Ten developers running tests in parallel, and the first ones get banned.
  • No reliable sandbox: OKEx and Coinbase Pro have test environments, but their availability isn't guaranteed, the data isn't representative, and authentication is yet another CI problem to solve.
  • Side effects: a test that places a real order during a demo is the kind of incident you don't forget.

Three approaches to work around this, in order of complexity:

Approach Use case Speed
HTTP mock (httptest.NewServer) Test API response parsing Very fast
Interface mock (mockgen) Test business logic in isolation Very fast
Testcontainers Integration tests with a real DB Slow (10-30s)

Defining an Exchange interface

The key insight: define a Go interface that all exchange clients implement. This is the foundation of testability. Without it, the service depends on a concrete type and becomes impossible to mock properly.

// exchange/client.go

type OrderBook struct {
    Bids []Level `json:"bids"`
    Asks []Level `json:"asks"`
}

type Level struct {
    Price decimal.Decimal
    Qty   decimal.Decimal
}

type Order struct {
    Pair     string
    Side     string // "buy" | "sell"
    Quantity decimal.Decimal
    Price    decimal.Decimal
}

type OrderResult struct {
    OrderID   string
    Status    string
    FilledQty decimal.Decimal
}

// ExchangeClient is the interface all exchange clients implement.
// The service depends on this interface, never on concrete types.
type ExchangeClient interface {
    GetOrderBook(ctx context.Context, pair string) (*OrderBook, error)
    PlaceOrder(ctx context.Context, order Order) (*OrderResult, error)
    GetBalance(ctx context.Context) (map[string]decimal.Decimal, error)
}

Each exchange implements the interface. The service receives ExchangeClient as a parameter — not *BinanceClient or *CoinbaseClient:

// exchange/binance.go
type BinanceClient struct {
    baseURL    string
    apiKey     string
    secretKey  string
    httpClient *http.Client
}

func NewBinanceClient(apiKey, secretKey string) *BinanceClient {
    return &BinanceClient{
        baseURL:    "https://api.binance.com",
        apiKey:     apiKey,
        secretKey:  secretKey,
        httpClient: &http.Client{Timeout: 10 * time.Second},
    }
}

// BinanceClient implements ExchangeClient.
func (c *BinanceClient) GetOrderBook(ctx context.Context, pair string) (*OrderBook, error) {
    // real HTTP call to Binance
}

// exchange/coinbase.go
type CoinbaseClient struct { /* ... */ }

func (c *CoinbaseClient) GetOrderBook(ctx context.Context, pair string) (*OrderBook, error) {
    // real HTTP call to Coinbase
}
// aggregator/service.go
type AggregatorService struct {
    exchanges []ExchangeClient
}

func NewAggregatorService(exchanges ...ExchangeClient) *AggregatorService {
    return &AggregatorService{exchanges: exchanges}
}

// In tests, pass mocks. In production, pass real clients.
func main() {
    binance := exchange.NewBinanceClient(os.Getenv("BINANCE_KEY"), os.Getenv("BINANCE_SECRET"))
    coinbase := exchange.NewCoinbaseClient(os.Getenv("COINBASE_KEY"), os.Getenv("COINBASE_SECRET"))

    svc := aggregator.NewAggregatorService(binance, coinbase)
}

Mocks with mockgen

Once the interface is defined, mockgen generates mocks automatically. Don't write them by hand — maintenance becomes a nightmare as soon as the interface evolves.

// exchange/client.go
// Add this directive to generate mocks

//go:generate mockgen -destination=../mocks/mock_exchange.go -package=mocks . ExchangeClient
go generate ./exchange/...

The generated file at mocks/mock_exchange.go provides a ready-to-use MockExchangeClient. Table-driven test for a service that aggregates order books from multiple exchanges:

// aggregator/service_test.go

func TestAggregateOrderBooks(t *testing.T) {
    tests := []struct {
        name        string
        binanceOB   *exchange.OrderBook
        coinbaseOB  *exchange.OrderBook
        binanceErr  error
        coinbaseErr error
        wantBestBid decimal.Decimal
        wantErr     bool
    }{
        {
            name: "normal case — best bid on Coinbase",
            binanceOB: &exchange.OrderBook{
                Bids: []exchange.Level{
                    {Price: decimal.NewFromFloat(65000), Qty: decimal.NewFromFloat(0.5)},
                },
            },
            coinbaseOB: &exchange.OrderBook{
                Bids: []exchange.Level{
                    {Price: decimal.NewFromFloat(65100), Qty: decimal.NewFromFloat(0.3)},
                },
            },
            wantBestBid: decimal.NewFromFloat(65100),
        },
        {
            name:       "exchange down — return an error",
            binanceErr: errors.New("connection refused"),
            wantErr:    true,
        },
        {
            name: "empty order book — skip that exchange",
            binanceOB: &exchange.OrderBook{
                Bids: []exchange.Level{
                    {Price: decimal.NewFromFloat(64900), Qty: decimal.NewFromFloat(1.0)},
                },
            },
            coinbaseOB: &exchange.OrderBook{Bids: []exchange.Level{}},
            wantBestBid: decimal.NewFromFloat(64900),
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            ctrl := gomock.NewController(t)
            defer ctrl.Finish()

            binanceMock := mocks.NewMockExchangeClient(ctrl)
            coinbaseMock := mocks.NewMockExchangeClient(ctrl)

            binanceMock.EXPECT().
                GetOrderBook(gomock.Any(), "BTCUSDT").
                Return(tt.binanceOB, tt.binanceErr)

            if tt.binanceErr == nil {
                coinbaseMock.EXPECT().
                    GetOrderBook(gomock.Any(), "BTCUSDT").
                    Return(tt.coinbaseOB, tt.coinbaseErr)
            }

            svc := aggregator.NewAggregatorService(binanceMock, coinbaseMock)
            result, err := svc.GetBestBid(context.Background(), "BTCUSDT")

            if tt.wantErr {
                require.Error(t, err)
                return
            }
            require.NoError(t, err)
            assert.True(t, tt.wantBestBid.Equal(result),
                "expected %s, got %s", tt.wantBestBid, result)
        })
    }
}

The test verifies the service's behavior, not its implementation. If OKEx is added tomorrow, you add a mock in the tests without touching any existing logic.

HTTP mock server for testing parsing

Interface mocks test business logic, but not the HTTP layer. If Binance changes its response format, the mocks won't catch it — they return Go structs, not JSON.

To test that BinanceClient.GetOrderBook() correctly parses the real API format, use httptest.NewServer:

// exchange/binance_test.go

func TestBinanceOrderBookParsing(t *testing.T) {
    // Real format from Binance API GET /api/v3/depth
    server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        assert.Equal(t, "/api/v3/depth", r.URL.Path)
        assert.Equal(t, "BTCUSDT", r.URL.Query().Get("symbol"))

        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(map[string]interface{}{
            "lastUpdateId": 1027024,
            "bids": [][]string{
                {"65000.00000000", "0.50000000"},
                {"64950.00000000", "1.20000000"},
            },
            "asks": [][]string{
                {"65001.00000000", "0.30000000"},
                {"65010.00000000", "0.80000000"},
            },
        })
    }))
    defer server.Close()

    // Point the client at the test server
    client := exchange.NewBinanceClientWithBaseURL(server.URL, "test-key", "test-secret")

    ob, err := client.GetOrderBook(context.Background(), "BTCUSDT")

    require.NoError(t, err)
    require.Len(t, ob.Bids, 2)
    require.Len(t, ob.Asks, 2)

    assert.True(t, decimal.NewFromFloat(65000).Equal(ob.Bids[0].Price))
    assert.True(t, decimal.NewFromFloat(0.5).Equal(ob.Bids[0].Qty))
    assert.True(t, decimal.NewFromFloat(65001).Equal(ob.Asks[0].Price))
}

This test catches real bugs: renamed field, unexpected decimal format, unexpected element order in the array. It also documents exactly what the client expects from the API — useful when Binance ships a breaking change.

For this to work, the constructor needs a configurable baseURL. Good practice anyway — it lets you point to Binance testnet or a proxy in production:

func NewBinanceClientWithBaseURL(baseURL, apiKey, secretKey string) *BinanceClient {
    return &BinanceClient{
        baseURL:    baseURL,
        apiKey:     apiKey,
        secretKey:  secretKey,
        httpClient: &http.Client{Timeout: 10 * time.Second},
    }
}

func NewBinanceClient(apiKey, secretKey string) *BinanceClient {
    return NewBinanceClientWithBaseURL("https://api.binance.com", apiKey, secretKey)
}

Testcontainers for integration tests

Mocks cover business logic and HTTP parsing. One case remains: verifying that trades are correctly persisted to the database. A real PostgreSQL is needed here — DB mocks don't test real SQL queries, constraints, or transactions.

testcontainers-go starts a Docker container from within the test, then tears it down when done. No manual setup, no shared database where developers step on each other:

// repository/trade_test.go
// +build integration

func TestSaveTradeIntegration(t *testing.T) {
    ctx := context.Background()

    pg, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
        ContainerRequest: testcontainers.ContainerRequest{
            Image:        "postgres:16",
            ExposedPorts: []string{"5432/tcp"},
            Env: map[string]string{
                "POSTGRES_PASSWORD": "test",
                "POSTGRES_DB":       "trades_test",
                "POSTGRES_USER":     "test",
            },
            WaitingFor: wait.ForListeningPort("5432/tcp"),
        },
        Started: true,
    })
    require.NoError(t, err)
    defer pg.Terminate(ctx)

    host, _ := pg.Host(ctx)
    port, _ := pg.MappedPort(ctx, "5432/tcp")

    dsn := fmt.Sprintf("postgres://test:test@%s:%s/trades_test?sslmode=disable", host, port.Port())
    db, err := sqlx.ConnectContext(ctx, "pgx", dsn)
    require.NoError(t, err)
    defer db.Close()

    // Apply migrations
    require.NoError(t, runMigrations(db))

    repo := repository.NewTradeRepository(db)

    trade := &model.Trade{
        Exchange:   "binance",
        Pair:       "BTCUSDT",
        Side:       "buy",
        Price:      decimal.NewFromFloat(65000),
        Quantity:   decimal.NewFromFloat(0.1),
        ExecutedAt: time.Now().UTC(),
    }

    err = repo.Save(ctx, trade)
    require.NoError(t, err)
    require.NotEmpty(t, trade.ID)

    // Verify persistence
    saved, err := repo.GetByID(ctx, trade.ID)
    require.NoError(t, err)
    assert.Equal(t, "binance", saved.Exchange)
    assert.True(t, trade.Price.Equal(saved.Price))
}

The // +build integration build tag isolates these tests from unit tests. Run them separately:

# Unit tests only (fast, run on every commit)
go test ./...

# Integration tests (slow, run on main branch or pre-deploy)
go test -tags=integration ./...

Testcontainers takes between 10 and 30 seconds to start the container (depending on whether the image is cached). Acceptable for an integration suite, not for unit tests run 50 times a day.

What not to test

With mocks and httptest, it's tempting to test everything. Three things to avoid:

Testing that the Binance API works. That's not your code. If the Binance API is down, your service should detect the error and propagate it correctly — that's what you test, not Binance's uptime.

Over-mocking. If every call returns exactly what you told it to return, you end up testing the mock, not the service. A test that passes because the mocks are perfectly configured to pass is a false positive. The httptest.NewServer with real JSON is more honest than a mock that returns a Go struct directly.

Testing implementation rather than behavior. A test that verifies GetOrderBook was called exactly twice in a specific order is brittle. What matters: the service returns the best bid. How it gets there is its own business.

Conclusion

The layered structure — Exchange interface → business service → repository — isn't an abstraction for its own sake. It's what makes testing possible without hitting real APIs. The business service doesn't know whether it's talking to Binance or a mock. The test mock doesn't know whether the service actually calls the exchange.

In practice, all three test levels coexist in the project: unit tests with mockgen for fast logic, httptest.NewServer to validate API response parsing, and testcontainers for persistence integration tests. Each has its role. None replaces the others.

The most valuable test is often the HTTP parsing one. Business logic changes slowly. Exchange API formats change without warning.

Comments (0)