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.