Tests et débogage#

Tester un programme Solana n’est pas une tâche ordinaire. Contrairement à un binaire classique que l’on exécute localement, un programme on-chain vit dans un environnement singulier : il est déployé sur un réseau de validateurs, invoqué par des transactions signées, et contraint par un budget de compute units. Les outils de test doivent donc reproduire cet environnement avec suffisamment de fidélité pour que les bugs soient détectés avant le déploiement, tout en restant assez rapides pour s’intégrer dans une boucle de développement itérative.

L’écosystème Solana propose trois niveaux de test complémentaires. Le premier, et le plus courant avec Anchor, repose sur des tests TypeScript qui génèrent automatiquement un client type à partir de l’IDL du programme. Le deuxième utilise le crate Rust solana-program-test, qui instancie un validateur léger en mémoire pour des tests unitaires rapides. Le troisième lance un validateur local complet (solana-test-validator) capable de cloner des comptes depuis le mainnet. A ces trois niveaux s’ajoutent des outils de débogage — la macro msg!(), les logs en temps réel, et les évènements Anchor — qui permettent d’inspecter le comportement d’un programme à chaque étape de son exécution.

Ce chapitre parcourt méthodiquement ces outils. Le lecteur, familier avec les tests en Rust grâce au chapitre correspondant du Rust Book, retrouvera ici des concepts connus (#[test], assertions, gestion des erreurs) transposés dans le contexte spécifique de Solana. L’accent est mis sur ce qui diffère : la génération automatique de clients TypeScript, le BanksClient, le validateur local, et les contraintes propres aux programmes on-chain.

Tests TypeScript avec Anchor#

Définition 97 (Framework de test Anchor)

Le framework de test Anchor repose sur Mocha (framework de test JavaScript) et Chai (bibliothèque d’assertions). Lors de anchor build, Anchor génère un fichier IDL (Interface Description Language) au format JSON qui décrit les instructions, comptes et types du programme. A partir de cet IDL, la bibliothèque @coral-xyz/anchor construit dynamiquement un client TypeScript type : chaque instruction du programme devient une méthode appelable avec autocomplétion et vérification de types.

Les tests sont placés dans le répertoire tests/ et exécutés par anchor test, qui :

  1. Compile le programme (anchor build).

  2. Démarre un validateur local.

  3. Déploie le programme sur ce validateur.

  4. Exécute les tests Mocha.

  5. Arrête le validateur.

Voici un fichier de test complet pour le programme de compteur du chapitre 8. Ce programme expose deux instructions : initialize (crée un compteur a 0) et increment (incrémente de 1, uniquement par l’autorité).

import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { Counter } from "../target/types/counter";
import { assert } from "chai";
import { Keypair, SystemProgram } from "@solana/web3.js";

describe("counter", () => {
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);
  const program = anchor.workspace.Counter as Program<Counter>;
  const counter = Keypair.generate();

  it("initialise le compteur à zéro", async () => {
    await program.methods
      .initialize()
      .accounts({
        counter: counter.publicKey,
        authority: provider.wallet.publicKey,
        systemProgram: SystemProgram.programId,
      })
      .signers([counter])
      .rpc();

    const account = await program.account.counter.fetch(counter.publicKey);
    assert.equal(account.count.toNumber(), 0);
    assert.ok(account.authority.equals(provider.wallet.publicKey));
  });

  it("incremente le compteur de 1", async () => {
    await program.methods
      .increment()
      .accounts({
        counter: counter.publicKey,
        authority: provider.wallet.publicKey,
      })
      .rpc();

    const account = await program.account.counter.fetch(counter.publicKey);
    assert.equal(account.count.toNumber(), 1);
  });

  it("échoue si l'autorité est incorrecte", async () => {
    const fakeAuthority = Keypair.generate();
    try {
      await program.methods
        .increment()
        .accounts({
          counter: counter.publicKey,
          authority: fakeAuthority.publicKey,
        })
        .signers([fakeAuthority])
        .rpc();
      assert.fail("La transaction aurait du échouer");
    } catch (err) {
      assert.equal(err.error.errorCode.code, "ConstraintHasOne");
    }
  });
});

Remarque 62 (Le mécanisme anchor.workspace)

L’objet anchor.workspace est le point d’entrée vers tous les programmes du projet. Anchor découvre automatiquement les programmes déclarés dans Anchor.toml, charge leur IDL, et génère un client type pour chacun. Ainsi, anchor.workspace.Counter donne accès à un objet Program<Counter> dont les méthodes correspondent exactement aux instructions définies dans le programme Rust. Ce mécanisme élimine la sérialisation manuelle des instructions et des comptes : le client sait quels comptes chaque instruction attend, quels sont leurs types, et quels signataires sont requis.

Exemple 29 (Envoyer une transaction avec le client Anchor)

La syntaxe chainée du client Anchor suit un schema constant :

const txSignature = await program.methods
  .nomDeLInstruction(arg1, arg2)   // Instruction et arguments
  .accounts({                       // Comptes requis
    compte1: pubkey1,
    compte2: pubkey2,
    systemProgram: SystemProgram.programId,
  })
  .signers([keypair1, keypair2])   // Signataires additionnels
  .rpc();                           // Envoyer la transaction

La méthode .rpc() envoie la transaction et retourne la signature. Alternativement, .transaction() construit l’objet Transaction sans l’envoyer, et .instruction() retourne l’instruction Solana brute.

Remarque 63 (Options de confirmation et niveaux de commitment)

Lorsqu’une transaction est envoyée, elle passe par trois niveaux de confirmation :

  • processed : traitée par le leader actuel, pas encore confirmée par le cluster. Rapide mais la transaction peut etre annulée.

  • confirmed : confirmée par une super-majorité de validateurs. Niveau par défaut, recommandé pour les tests.

  • finalized : inscrite dans un bloc ayant atteint la finalité maximale (~32 slots). Le plus sûr, mais le plus lent.

const provider = new anchor.AnchorProvider(
  connection, wallet,
  { commitment: "confirmed", preflightCommitment: "confirmed" }
);

Pour les tests locaux, confirmed suffit. En production, les opérations critiques devraient attendre finalized.

Tests en Rust avec solana-program-test#

Définition 98 (solana-program-test et BanksClient)

Le crate solana-program-test fournit un validateur léger en mémoire pour tester les programmes Solana en Rust. Son composant central est le BanksClient, une interface qui émule un validateur complet sans processus externe.

L’initialisation suit trois étapes :

  1. Créer un ProgramTest en spécifiant le nom du programme et son process_instruction.

  2. Appeler .start() pour obtenir un triplet (BanksClient, Keypair, Hash) — le client, le payeur, et le hash récent.

  3. Construire des transactions, les envoyer via banks_client.process_transaction(), et inspecter les comptes.

Le BanksClient s’exécute en quelques millisecondes, ce qui le rend idéal pour les tests unitaires rapides.

#[cfg(test)]
mod tests {
    use solana_program_test::*;
    use solana_sdk::{
        instruction::{AccountMeta, Instruction},
        pubkey::Pubkey, signature::Signer,
        transaction::Transaction, system_instruction,
    };
    use borsh::BorshDeserialize;

    #[derive(BorshDeserialize, Debug)]
    struct CounterAccount {
        pub authority: Pubkey,
        pub count: u64,
    }

    #[tokio::test]
    async fn test_initialize_counter() {
        let program_id = Pubkey::new_unique();
        let mut program_test = ProgramTest::new(
            "counter", program_id,
            processor!(process_instruction),
        );

        let (mut banks_client, payer, recent_blockhash) =
            program_test.start().await;

        let counter = solana_sdk::signature::Keypair::new();
        let space = 8 + 32 + 8; // discriminateur + authority + count
        let rent = banks_client.get_rent().await.unwrap()
            .minimum_balance(space);

        let create_ix = system_instruction::create_account(
            &payer.pubkey(), &counter.pubkey(),
            rent, space as u64, &program_id,
        );

        let init_ix = Instruction {
            program_id,
            accounts: vec![
                AccountMeta::new(counter.pubkey(), false),
                AccountMeta::new_readonly(payer.pubkey(), true),
                AccountMeta::new_readonly(solana_sdk::system_program::id(), false),
            ],
            data: vec![0], // Discriminateur pour 'initialize'
        };

        let mut tx = Transaction::new_with_payer(
            &[create_ix, init_ix], Some(&payer.pubkey()),
        );
        tx.sign(&[&payer, &counter], recent_blockhash);
        banks_client.process_transaction(tx).await.unwrap();

        let account = banks_client.get_account(counter.pubkey())
            .await.unwrap().unwrap();
        let data = CounterAccount::try_from_slice(&account.data[8..]).unwrap();
        assert_eq!(data.count, 0);
        assert_eq!(data.authority, payer.pubkey());
    }
}

Remarque 64 (Avantages des tests Rust)

Les tests avec solana-program-test offrent plusieurs avantages :

  • Vitesse : le BanksClient s’exécute dans le même processus, sans validateur externe. Un test s’execute en quelques millisecondes.

  • Acces direct : on peut tester des fonctions internes du programme sans passer par l’interface publique.

  • Integration cargo test : les tests suivent les conventions Rust standard (parallelisme, filtrage, --nocapture).

  • Controle fin : on peut injecter des comptes pre-configures, simuler l’avancement du temps (warp), ou forcer des etats specifiques.

Remarque 65 (Inconvenients et compromis)

En contrepartie, les tests Rust sont nettement plus verbeux. Chaque instruction doit etre construite manuellement : specifier les AccountMeta, serialiser les donnees, calculer le loyer, creer les comptes. Le client Anchor fait tout cela automatiquement a partir de l’IDL.

De plus, le BanksClient ne simule pas les latences, les reorganisations de blocs ni les conditions de concurrence. En pratique, une strategie mixte est recommandee : tests Rust pour la logique unitaire critique, tests TypeScript pour les scenarios d’integration.

Le validateur local#

Définition 99 (solana-test-validator)

Le solana-test-validator est un validateur Solana complet qui s’execute localement. Contrairement au BanksClient, c’est un processus independant qui expose une interface RPC standard sur http://localhost:8899. Il supporte l’ensemble des fonctionnalites d’un validateur de production : traitement des transactions, avancement des slots, calcul du loyer, et execution des programmes. Tout client Solana (CLI, SDK TypeScript, SDK Rust) peut s’y connecter comme a un cluster distant.

# Lancer le validateur (persistant entre les redemarrages)
solana-test-validator

# Repartir d'un etat vierge
solana-test-validator --reset

# Pre-charger un programme compile
solana-test-validator \
  --bpf-program <PROGRAM_ID> ./target/deploy/counter.so --reset

# Cloner un compte depuis le mainnet
solana-test-validator \
  --clone <ACCOUNT_ADDRESS> --url mainnet-beta --reset

# Cloner un programme entier depuis le mainnet
solana-test-validator \
  --clone-upgradeable-program <PROGRAM_ID> --url mainnet-beta --reset

Exemple 30 (Cloner un pool Raydium depuis le mainnet)

Pour tester l’interaction avec un pool Raydium sans risquer de fonds :

RAYDIUM_PROGRAM=675kPX9MHTjS2zt1qfr1NYHuzeLXfQM9H24wFSUt1Mp8
POOL_STATE=58oQChx4yWmvKdwLLZzBi4ChoCc2fqCUWBkwMihLYQo2

solana-test-validator \
  --clone-upgradeable-program $RAYDIUM_PROGRAM \
  --clone $POOL_STATE \
  --url mainnet-beta --reset

Le validateur local contient maintenant une copie exacte du programme Raydium et du compte du pool. On peut y envoyer des transactions de swap identiques a celles du mainnet, sans frais reels. Cette technique est essentielle pour le developpement de bots d’arbitrage et de protocoles DeFi.

Remarque 66 (anchor test et le validateur persistant)

La commande anchor test gere automatiquement le cycle de vie du validateur : demarrage, deploiement, execution des tests, arret. C’est le flux recommande pour le developpement quotidien.

Cependant, un validateur persistant est preferable dans certains cas :

  • Tests iteratifs : eviter le temps de redemarrage avec anchor test --skip-local-validator.

  • Comptes clones : lancer le validateur une seule fois avec les options --clone adequates.

  • Debogage interactif : inspecter l’etat des comptes entre les executions via solana account <ADDRESS>.

Logs et debogage#

Solana ne propose pas de debugger interactif. Le debogage repose sur la journalisation : on insere des messages dans le programme via la macro msg!(), et on les observe dans le flux de logs.

Définition 100 (La macro msg!())

La macro msg!() est la fonction de journalisation standard des programmes Solana. Elle ecrit un message dans le journal de la transaction, visible via la commande solana logs ou dans les explorateurs de blocs. Sa syntaxe est similaire a println!() en Rust, avec support du formatage :

msg!("Initialisation du compteur");
msg!("Autorite : {}", ctx.accounts.authority.key());
msg!("Increment: {} -> {}", old_count, new_count);

Les messages apparaissent dans les logs prefixes par Program log:.

use anchor_lang::prelude::*;

#[program]
pub mod counter {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        msg!("Initialisation du compteur");
        msg!("Autorite : {}", ctx.accounts.authority.key());
        let counter = &mut ctx.accounts.counter;
        counter.authority = ctx.accounts.authority.key();
        counter.count = 0;
        msg!("Compteur initialise, count = {}", counter.count);
        Ok(())
    }

    pub fn increment(ctx: Context<Increment>) -> Result<()> {
        let counter = &mut ctx.accounts.counter;
        let old_count = counter.count;
        counter.count += 1;
        msg!("Increment: {} -> {}", old_count, counter.count);
        Ok(())
    }
}
# Suivre tous les logs du cluster local
solana logs

# Filtrer par niveau de commitment
solana logs --commitment confirmed

# Filtrer par programme specifique
solana logs <PROGRAM_ID>

Exemple 31 (Scripts avec anchor run)

Anchor permet de definir des scripts personnalises dans Anchor.toml pour les taches repetitives :

# Dans Anchor.toml :
# [scripts]
# seed-data = "ts-node scripts/seed-data.ts"
# migrate = "ts-node scripts/migrate.ts"

anchor run seed-data
anchor run migrate

Ces scripts sont utiles pour le peuplement de comptes de test, les migrations de donnees, ou les etapes de deploiement automatisees.

Remarque 67 (msg!() et le budget de compute units)

Chaque appel a msg!() consomme des compute units (CU). Une transaction dispose par defaut de 200 000 CU, et chaque msg!() en consomme entre 100 et plusieurs milliers selon la longueur du message.

En developpement, la journalisation detaillee est precieuse. En production, un programme trop bavard peut epuiser son budget et provoquer l’echec de la transaction. La bonne pratique : msg!() genereux en developpement, strict minimum en production, eventuellement derriere un feature flag (#[cfg(feature = "verbose")]).

Remarque 68 (Evenements Anchor)

Pour un logging structure, Anchor propose les evenements via #[event]. Un evenement est une structure Rust serialisee dans les logs, capturable et decodable par les clients.

#[event]
pub struct CounterIncremented {
    pub authority: Pubkey,
    pub old_count: u64,
    pub new_count: u64,
}

// Dans l'instruction :
emit!(CounterIncremented {
    authority: ctx.accounts.authority.key(),
    old_count: old_count,
    new_count: counter.count,
});

Cote TypeScript :

const listener = program.addEventListener(
  "counterIncremented",
  (event, slot) => {
    console.log(`Increment: ${event.oldCount} -> ${event.newCount}`);
  }
);
program.removeEventListener(listener);

Les evenements consomment des CU mais offrent un format parsable par les clients, les indexeurs et les interfaces utilisateur.

Patterns de test#

Définition 101 (Les trois niveaux de test)

Les tests d’un programme Solana se decomposent en trois niveaux :

  1. Test du chemin nominal (happy path) : verifier que chaque instruction fonctionne correctement dans des conditions normales. C’est le premier test a ecrire.

  2. Test des cas d’erreur (error testing) : verifier que les operations invalides echouent avec l’erreur attendue. Un programme qui accepte des entrees invalides est un programme vulnerable.

  3. Test d’integration : verifier l’interaction entre plusieurs instructions et la coherence de l’etat global apres une sequence d’operations.

Le fichier de test du compteur presente plus haut illustre ces trois niveaux : le premier it() est un happy path, le deuxieme un test d’integration (il depend de l’initialisation), et le troisieme un test d’erreur.

Exemple 32 (Test d’integration avec sequence d’operations)

Un test d’integration typique enchaine plusieurs instructions et verifie la coherence finale :

it("supporte une sequence complete d'operations", async () => {
  const newCounter = Keypair.generate();

  await program.methods.initialize()
    .accounts({
      counter: newCounter.publicKey,
      authority: provider.wallet.publicKey,
      systemProgram: SystemProgram.programId,
    })
    .signers([newCounter]).rpc();

  for (let i = 0; i < 3; i++) {
    await program.methods.increment()
      .accounts({
        counter: newCounter.publicKey,
        authority: provider.wallet.publicKey,
      }).rpc();
  }

  const account = await program.account.counter.fetch(newCounter.publicKey);
  assert.equal(account.count.toNumber(), 3);
});

Ce test cree un compteur isole, l’incremente trois fois, puis verifie que l’etat final est coherent. L’isolation (nouveau Keypair a chaque test) garantit l’independance vis-a-vis des autres tests.

Cycle de developpement#

La visualisation ci-dessous represente le cycle iteratif du developpement d’un programme Solana.

Hide code cell source

import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
import numpy as np

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

fig, ax = plt.subplots(figsize=(14, 7))

steps = [
    "Ecrire le\ncode Rust",
    "anchor\nbuild",
    "anchor\ntest",
    "Corriger\nles erreurs",
    "Deployer sur\ndevnet",
    "Tester sur\ndevnet",
    "Deployer sur\nmainnet",
]

n = len(steps)
palette = sns.color_palette("muted", n_colors=n)

angles = np.linspace(np.pi / 2, np.pi / 2 - 2 * np.pi, n, endpoint=False)
radius = 3.0
xs = radius * np.cos(angles)
ys = radius * np.sin(angles)

for i in range(n):
    j = (i + 1) % n
    dx = xs[j] - xs[i]
    dy = ys[j] - ys[i]
    norm = np.sqrt(dx**2 + dy**2)
    shrink = 0.55
    ax.annotate(
        "",
        xy=(xs[i] + dx * (1 - shrink / norm), ys[i] + dy * (1 - shrink / norm)),
        xytext=(xs[i] + dx * (shrink / norm), ys[i] + dy * (shrink / norm)),
        arrowprops=dict(arrowstyle="-|>", color="#555555", lw=1.8, mutation_scale=18),
    )

for i, (x, y, label) in enumerate(zip(xs, ys, steps)):
    circle = plt.Circle((x, y), 0.55, color=palette[i], ec="white", lw=2, zorder=3)
    ax.add_patch(circle)
    ax.text(x, y, label, ha="center", va="center",
            fontsize=8, fontweight="bold", color="white", zorder=4)

ax.annotate(
    "Boucle\nrapide", xy=(xs[0] - 0.3, ys[0] - 0.55),
    xytext=(xs[3] + 0.3, ys[3] + 0.55),
    fontsize=8, color="#e74c3c", ha="center",
    arrowprops=dict(arrowstyle="-|>", color="#e74c3c", lw=1.5,
                    connectionstyle="arc3,rad=-0.3"),
)

ax.set_xlim(-4.5, 4.5)
ax.set_ylim(-4.5, 4.5)
ax.set_aspect("equal")
ax.set_title("Cycle de developpement d'un programme Solana", fontsize=13, fontweight="bold")
ax.axis("off")

plt.show()
_images/b00f6186539fd4f0493e4a4c663d180fb55bc86c54b402c5d06d6da17969b18c.png

Remarque 69 (Bonnes pratiques de test)

  1. Toujours tester les cas d’erreur. Un programme Solana est un contrat financier : une instruction qui accepte des entrees invalides peut entrainer des pertes irreversibles.

  2. Tester avec differents signataires. Les bugs de controle d’acces sont parmi les plus courants. Verifier systematiquement qu’un utilisateur non autorise ne peut pas invoquer une instruction protegee.

  3. Tester les cas limites. Que se passe-t-il si un montant vaut zero ? Si un compteur atteint u64::MAX ? Si un compte est deja initialise ?

  4. Isoler chaque test. Creer des comptes dedies par test pour eviter les dependances sur l’etat laisse par un test precedent.

  5. Strategie mixte. anchor test pour le flux quotidien, solana-program-test pour les tests unitaires critiques, validateur local avec --clone pour les tests d’integration avec des programmes externes.

Resume#

Concept

Description

Framework de test Anchor

Mocha + Chai, client TypeScript type genere depuis l’IDL

anchor.workspace

Decouverte automatique des programmes, generation de clients types

Commitment levels

processed (rapide), confirmed (defaut), finalized (maximal)

solana-program-test

Crate Rust avec BanksClient, validateur en memoire pour tests unitaires

BanksClient

Interface emulant un validateur complet, sans processus externe

solana-test-validator

Validateur local complet avec interface RPC standard

–clone

Copier des comptes depuis le mainnet ou le devnet vers le validateur local

msg!()

Macro de journalisation, visible dans solana logs

Compute units

Budget par transaction (200 000 CU par defaut), consomme par chaque operation

Evenements Anchor

Logging structure via #[event] et emit!(), parsable par les clients

Happy path

Verifier le comportement attendu en conditions normales

Error testing

Verifier que les operations invalides echouent avec l’erreur attendue

Integration testing

Tester l’interaction entre plusieurs instructions

Le chapitre suivant introduira les tokens SPL — le standard de jetons fongibles et non fongibles de Solana — et montrera comment les creer, transferer et gerer depuis un programme Anchor.