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.