Tester un client Go d'API exchange : mocks, httptest et testcontainers

Le service Go tourne en local, les tests passent. Puis tu te rappelles que les tests frappent la vraie API Binance. Le lendemain, le CI échoue : rate limit dépassé, réseau instable, ou tout simplement l'exchange a changé un champ dans sa réponse. Les tests externes sont au mieux fragiles, au pire inutilisables en CI.

Le problème est structurel : les exchanges n'ont pas d'environnement sandbox utilisable (Binance testnet existe, mais avec des contraintes qui rendent les tests d'intégration difficiles à automatiser), les rate limits sont agressifs, et certaines opérations comme PlaceOrder ont des effets de bord réels. Il faut une autre approche.

Le problème des API externes en test

Trois raisons concrètes pour lesquelles on ne peut pas tester contre les vraies API d'exchange en CI :

  • Rate limits : Binance limite à 1200 requests/minute sur le endpoint order book. Dix développeurs qui lancent les tests en parallèle, et les premiers sont bannis.
  • Pas de sandbox fiable : OKEx et Coinbase Pro ont des environnements de test, mais leur disponibilité n'est pas garantie, les données ne sont pas représentatives, et l'authentification est un problème supplémentaire à gérer en CI.
  • Effets de bord : un test qui passe un ordre sur la vraie API pendant une démonstration, c'est le genre d'incident dont on se souvient longtemps.

Les trois approches pour contourner ça, par ordre de complexité :

Approche Usage Vitesse
Mock HTTP (httptest.NewServer) Tester le parsing des réponses API Très rapide
Mock d'interface (mockgen) Tester la logique métier isolée Très rapide
Testcontainers Tests d'intégration avec vraie BDD Lent (10-30s)

Définir une interface Exchange

L'insight clé : définir une interface Go que tous les clients d'exchange implémentent. C'est la fondation de la testabilité. Sans ça, le service dépend d'un type concret et devient impossible à mocker proprement.

// 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 est l'interface que tous les clients d'exchange implémentent.
// Le service dépend de cette interface, jamais des types concrets.
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)
}

Chaque exchange implémente l'interface. Le service reçoit ExchangeClient en paramètre — pas *BinanceClient ni *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 implémente ExchangeClient.
func (c *BinanceClient) GetOrderBook(ctx context.Context, pair string) (*OrderBook, error) {
    // appel HTTP réel vers Binance
}

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

func (c *CoinbaseClient) GetOrderBook(ctx context.Context, pair string) (*OrderBook, error) {
    // appel HTTP réel vers Coinbase
}
// aggregator/service.go
type AggregatorService struct {
    exchanges []ExchangeClient
}

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

// Dans les tests, on passe des mocks. En prod, on passe les vrais 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 avec mockgen

Une fois l'interface définie, mockgen génère les mocks automatiquement. On ne les écrit pas à la main — la maintenance serait un cauchemar dès que l'interface évolue.

// exchange/client.go
// Ajouter cette directive pour générer les mocks

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

Le fichier généré dans mocks/mock_exchange.go fournit un MockExchangeClient prêt à l'emploi. Test table-driven pour un service qui agrège les order books de plusieurs 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: "cas nominal — meilleur bid sur 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 — retourner une erreur",
            binanceErr: errors.New("connection refused"),
            wantErr:    true,
        },
        {
            name: "order book vide — ignorer cet 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),
                "attendu %s, obtenu %s", tt.wantBestBid, result)
        })
    }
}

Le test vérifie le comportement du service, pas l'implémentation. Si demain on ajoute OKEx, on ajoute un mock dans les tests sans toucher à la logique existante.

Serveur HTTP mock pour tester le parsing

Les mocks d'interface testent la logique métier, mais pas la couche HTTP. Si Binance change le format de sa réponse, les mocks ne le détecteront pas — ils retournent des structs Go, pas du JSON.

Pour tester que BinanceClient.GetOrderBook() parse correctement le format réel de l'API, on utilise httptest.NewServer :

// exchange/binance_test.go

func TestBinanceOrderBookParsing(t *testing.T) {
    // Format réel de l'API Binance 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()

    // Pointer le client vers le serveur de test
    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))
}

Ce test attrape les vrais bugs : champ renommé, format décimal inattendu, ordre des éléments dans le tableau. Il documente aussi exactement ce que le client attend de l'API — utile quand Binance sort un breaking change.

Pour que ça fonctionne, le constructeur doit accepter un baseURL configurable. C'est une bonne pratique de toute façon — ça permet de pointer vers le testnet Binance ou un proxy en 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 pour les tests d'intégration

Les mocks couvrent la logique métier et le parsing HTTP. Il reste un cas : vérifier que les trades sont correctement persistés en base. Pour ça, un vrai PostgreSQL est nécessaire — les mocks de BDD ne testent pas les vraies requêtes SQL, les contraintes, ni les transactions.

testcontainers-go démarre un conteneur Docker depuis le test, puis le détruit à la fin. Pas de setup manuel, pas de base partagée entre développeurs qui se marchent dessus :

// 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()

    // Appliquer les 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)

    // Vérifier la persistance
    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))
}

Le build tag // +build integration isole ces tests des tests unitaires. On les lance séparément :

# Tests unitaires uniquement (rapides, lancés à chaque commit)
go test ./...

# Tests d'intégration (lents, lancés sur la branche main ou en pre-deploy)
go test -tags=integration ./...

Testcontainers prend entre 10 et 30 secondes pour démarrer le conteneur (dépend de la présence de l'image en cache). C'est acceptable pour une suite d'intégration, pas pour des tests unitaires lancés 50 fois par jour.

Ce qu'on ne teste pas

Avec les mocks et httptest, il est tentant de tout tester. Trois choses à ne pas faire :

Tester que l'API Binance fonctionne. Ce n'est pas votre code. Si l'API Binance est en panne, votre service devrait détecter l'erreur et la propager correctement — c'est ça qu'on teste, pas la disponibilité de Binance.

Trop mocker. Si chaque appel retourne exactement ce qu'on lui dit de retourner, on finit par tester le mock, pas le service. Un test qui passe parce que les mocks sont parfaitement configurés pour passer est un faux positif. Le httptest.NewServer avec le vrai format JSON est plus honnête que le mock qui retourne directement une struct Go.

Tester l'implémentation plutôt que le comportement. Un test qui vérifie que GetOrderBook a été appelé exactement deux fois dans un ordre précis est fragile. Ce qui compte : le service retourne le meilleur bid. Comment il l'obtient, c'est son affaire.

Conclusion

La structure en couches — interface Exchange → service métier → repository — n'est pas une abstraction pour faire joli. C'est ce qui rend les tests possibles sans frapper les vraies API. Le service métier ne sait pas s'il parle à Binance ou à un mock. Le mock de l'interface de test ne sait pas si le service appelle vraiment l'exchange.

En pratique, les trois niveaux de tests coexistent dans le projet : les tests unitaires avec mockgen pour la logique rapide, httptest.NewServer pour valider le parsing des réponses API, et testcontainers pour les tests d'intégration sur la persistance. Chacun a son rôle. Aucun ne remplace les autres.

Le test le plus précieux reste souvent celui du parsing HTTP. La logique métier change lentement. Le format d'une API d'exchange, lui, change sans préavis.

Commentaires (0)