NEU: Helius übernimmt Light Protocol
Leitfaden zum Testen von Solana-Programmen
Blog/Entwicklung

Leitfaden zum Testen von Solana-Programmen

Developer Experience Engineer0xIchigo auf X0xIchigo auf LinkedIn0xIchigo auf GitHub
27 Min. Lesezeit

Einführung

Tests in einer Blockchain-Umgebung gehen über das klassische Paradigma des Softwaretestens hinaus. Sie bringen besondere Herausforderungen und weitreichendere Konsequenzen mit sich. In Solanas Umgebung mit hohem Durchsatz und niedriger Latenz gibt es wenig Spielraum für Fehler. Automatisierte Tests sind daher nicht nur eine Best Practice, sondern eine grundlegende Voraussetzung, um die Zuverlässigkeit und Sicherheit von Programmen in Solanas dynamischer und unnachgiebiger Umgebung zu gewährleisten.

Dieser Artikel behandelt die wichtigsten Arten automatisierter Tests: Unit-Tests, Integrationstests und End-to-End-Tests (E2E). Außerdem zeigt er, wie du grundlegende Unit-Tests in JavaScript/TypeScript und Rust schreibst, bevor er beliebte Test-Frameworks für Solana analysiert. Zum Abschluss folgt ein praktisches Beispiel, das ein „King of the Hill“-Spielprogramm testet.

Falls Solana neu für dich ist, empfehle ich dir, zuerst diese früheren Blogbeiträge zu lesen:

Dieser Artikel ergänzt außerdem unseren früheren Artikel über die Sicherheit von Solana-Programmen. Ich empfehle dir, beide zusammen zu lesen.

Was sind Tests?

Tests prüfen, ob ein Codeabschnitt oder eine gesamte Anwendung wie vorgesehen funktioniert. Es gibt zwei allgemeine Arten von Tests:

  • Manuelle Tests: ein auf Menschen ausgerichteter Prozess, bei dem Entwickler, Qualitätssicherungsanalysten, Penetrationstester oder andere Verantwortliche Testfälle ausführen
  • Automatisierte Tests: ein codebasierter Prozess, bei dem Skripte geschrieben werden, um vordefinierte Testfälle programmatisch auszuführen

Manuelle Tests sind äußerst flexibel und unabhängig von der Art der getesteten Anwendung. Sie eignen sich gut zum Testen neuer Funktionen, der Benutzerfreundlichkeit und der Barrierefreiheit. Dabei verlässt man sich auf die Intuition der testenden Person, wie sich eine Anwendung verhalten sollte. Da im Testprozess jedoch kaum Werkzeuge eingesetzt werden, sind manuelle Tests grundsätzlich langsam, fehleranfällig, zeitaufwendig und oft unvollständig, da beispielsweise nicht alle Szenarien abgedeckt werden. 

Automatisierte Tests sollen die Nachteile manueller Tests ausgleichen. Sie sind beispielsweise meist schneller, insbesondere wenn Tests parallel ausgeführt werden. Da sie einem vordefinierten Skript folgen, sind sie weniger anfällig für menschliche Fehler. Sie erhöhen die Testabdeckung, weil sie viele Testfälle effizient verarbeiten können, und sind dadurch hochgradig skalierbar. Aufgrund ihrer starren Objektivität sind automatisierte Tests jedoch weniger zuverlässig, wenn menschliche Interaktion, Urteilsvermögen oder kritisches Denken erforderlich sind.

In diesem Artikel konzentrieren wir uns auf automatisierte Tests für unsere Solana-Programme. Manuelle Tests wären im Mainnet recht teuer und im Devnet sehr zeitaufwendig. Dennoch sollten sowohl manuelle als auch automatisierte Tests Teil deines Testprozesses sein, bevor du Code in die Produktion überführst. Ein robuster Testprozess kann die Zahl der Bugs in der Produktion reduzieren, indem er sie bereits früh in der Entwicklung erkennt.

Es gibt verschiedene Arten automatisierter Tests:

  • Unit-Tests
  • Integrationstests
  • End-to-End-Tests (E2E)

Unit-Tests

Bei Unit-Tests werden die kleinsten funktionalen Einheiten des Codes geprüft, um sicherzustellen, dass sie korrekt funktionieren. Idealerweise sind Units die kleinstmöglichen Bausteine eines Programms, etwa einzelne Funktionen oder Module, die zusammen das fertige Produkt ergeben. Der Kerngedanke lautet: Wenn wir die einzelnen Bausteine gründlich testen, sollte das gesamte Programm wie vorgesehen funktionieren. 

Unit-Tests bilden die Grundlage der Solana-Entwicklung, da sie sicherstellen, dass sich jeder Teil eines Programms wie vorgesehen verhält. Weil sich diese Tests wiederverwenden lassen, kannst du prüfen, ob neue Funktionen oder Updates den Projektspezifikationen und den im Testfall definierten Erwartungen der Nutzer entsprechen. Unit-Tests fördern dadurch von Natur aus die Codeoptimierung und das Refactoring, damit neue Verbesserungen die Funktionalität des Programms nicht beeinträchtigen. Sie gewährleisten nicht nur, dass jeder Codeabschnitt unter verschiedenen Testbedingungen korrekt funktioniert, sondern auch effiziente und sichere Blockchain-Interaktionen. Bugs frühzeitig durch Unit-Tests zu erkennen, ist entscheidend. So verhinderst du, dass potenzielle Schwachstellen in die Produktion gelangen. 

Verschiedene Test-Frameworks vereinfachen Unit-Tests, indem sie die Simulation von Netzwerkbedingungen und die Verwaltung des Programmzustands erleichtern. Darauf gehen wir später in diesem Artikel ein. Mit Unit-Tests können Solana-Entwickler ein hohes Maß an Zuverlässigkeit und Performance ihres Codes erreichen.

Integrationstests

Integrationstests gehen über Unit-Tests hinaus und untersuchen, wie verschiedene Einheiten eines Programms zusammenarbeiten. Für die Solana-Entwicklung ist es entscheidend, zu prüfen, ob Funktionen und Module eines Programms korrekt zusammenspielen. Schließlich sind Programminteraktionen oft komplex und können finanzielle Folgen haben. Integrationstests sollen Probleme erkennen und beheben, die beim isolierten Testen einzelner Einheiten nicht auffallen, sondern erst durch die Interaktion der Komponenten entstehen. Dazu gehören inkompatible Datenformate, inkonsistente Typen, Programmabhängigkeiten oder Probleme mit APIs von Drittanbietern.

Da Programme auf Solana grundsätzlich mit anderen Programmen, Wallets und Oracles interagieren, prüfen Integrationstests, ob diese Interaktionen wie vorgesehen ablaufen. Selbst wenn jede Einheit fehlerfrei funktioniert, kann ihre Kombination unter den simulierten Bedingungen unerwartetes Verhalten oder Ineffizienzen verursachen. Entwickler können mit verschiedenen Test-Frameworks unterschiedliche Transaktionsabläufe und Programminteraktionen simulieren und so reale Szenarien möglichst genau nachbilden. Bankrun ist beispielsweise ein robustes, schlankes Test-Framework, mit dem Entwickler in der Zeit vor- und zurückspringen und Account-Daten dynamisch festlegen können. Mit solana-test-validator ist das nicht möglich. Integrationstests sind entscheidend, damit ein Programm robust, zuverlässig und auf die Anforderungen der Netzwerkbedingungen von Solana vorbereitet ist.

End-to-End-Tests (E2E)

End-to-End-Tests (E2E) bilden den Abschluss des Testprozesses. Sie bewerten den vollständigen Betriebsablauf eines Programms, wie er in realen Szenarien stattfinden würde. Diese Testmethode unterscheidet sich von Unit- und Integrationstests, da sie das Programm aus der Perspektive der Nutzer betrachtet: Alle möglichen Abläufe und Funktionen, mit denen Endnutzer in Berührung kommen, sollten wie vorgesehen funktionieren. 

E2E-Tests sind unverzichtbar, um zu prüfen, ob ein Programm seine funktionalen Anforderungen erfüllt und eine nahtlose Benutzererfahrung bietet. Diese Testphase deckt Probleme auf, die bei Unit- oder Integrationstests möglicherweise nicht sichtbar waren. Dazu gehören Verzögerungen bei der Transaktionsverarbeitung, Probleme mit der Zustandspersistenz, Optimierungen von Compute Units oder unerwartete Netzwerkbedingungen. E2E-Tests beziehen sich zwar normalerweise auf eine vollständige dApp, doch für ein erfolgreiches und sicheres Programm musst du auch dessen Betriebsabläufe testen und prüfen, wie die Transaktion eines Nutzers mit den verschiedenen Funktionen und Modulen interagiert.

Diese Testmethoden kombinieren

Für deinen Entwicklungsprozess ist eine mehrstufige Teststrategie entscheidend, die Unit-, Integrations- und E2E-Tests kombiniert. Jede Testmethode erfüllt im Entwicklungszyklus eine eigene Aufgabe und deckt unterschiedliche Aspekte der Funktionalität und Performance eines Programms ab.

Unit-Tests bilden die Grundlage eines mehrstufigen Testansatzes. Sie ermöglichen es Entwicklern, Probleme schnell auf der detailliertesten Codeebene zu erkennen und zu beheben. Sie eignen sich hervorragend, um die objektive Korrektheit einzelner Funktionen oder Module sicherzustellen. Allerdings berücksichtigen sie weder das Zusammenspiel dieser Einheiten noch ihre Rolle in der Benutzererfahrung.

Integrationstests schließen diese Lücke. Sie untersuchen die Interaktionen verschiedener Einheiten und decken Probleme auf, die bei ihrer Integration entstehen. Allein können sie die Erfahrung der Endnutzer oder das Verhalten des Programms unter realen Bedingungen jedoch möglicherweise nicht vollständig erfassen.

E2E-Tests ergänzen Unit- und Integrationstests, indem sie reale Nutzungsszenarien simulieren und Anwendungen als Ganzes prüfen. Dieser Ansatz ist für die Bewertung der gesamten Benutzererfahrung äußerst wertvoll. Er liefert jedoch nicht die detaillierten Erkenntnisse, die nötig sind, um konkrete Probleme schnell zu erkennen und zu beheben.

Durch die Kombination dieser Methoden können Entwickler ein robustes Test-Framework erstellen, das das gesamte Spektrum potenzieller Probleme abdeckt. Ein umfassender Ansatz verbessert nicht nur die Qualität und Sicherheit eines Programms, sondern optimiert auch den Entwicklungsprozess. Entwickler können schnell fundierte Entscheidungen treffen und Änderungen vornehmen, da sie wissen, dass ihre Änderungen auf mehreren Ebenen geprüft werden. Die Kombination dieser Methoden ist entscheidend, damit ein Solana-Programm vor dem Deployment technisch solide ist und unter realen Bedingungen die Erwartungen der Nutzer erfüllt.

Gute Tests schreiben

Effektive Tests sind für die Entwicklung zuverlässiger und sicherer Solana-Programme unerlässlich. Ein guter Test konzentriert sich auf das getestete Verhalten, nicht auf das verwendete Framework oder die Implementierungsdetails des Codes. Entwickler können eine effiziente Teststrategie schaffen und die Codequalität verbessern, indem sie die Prinzipien der testgetriebenen Entwicklung (TDD), das Arrange-Act-Assert-Muster (AAA) und Best Practices der Branche kombinieren.

Testgetriebene Entwicklung (TDD)

TDD ist ein robuster Ansatz zur Softwareentwicklung, bei dem Tests die Entwicklung steuern. Die zentrale Idee lautet: Schreibe die Tests vor dem eigentlichen Code. Der TDD-Zyklus besteht normalerweise aus drei Schritten:

  • Einen fehlschlagenden Test schreiben: Die Entwicklung sollte mit einem Test für die nächste Funktion beginnen, die ein Entwickler hinzufügen möchte. Dieser Test schlägt zwangsläufig fehl, da die zu testende Funktion noch nicht existiert
  • Code implementieren: Entwickler sollten nur so viel Code schreiben, wie nötig ist, damit der Test besteht. Hier zählen Geschwindigkeit und Einfachheit
  • Refactoring: Sobald der Test besteht, sollten Entwickler ihren Code überarbeiten, um dessen Struktur und Verständlichkeit zu verbessern, ohne sein Verhalten zu verändern. Der bestandene Test dient als Sicherheitsnetz gegen Breaking Changes. Dieser Schritt kann das Entfernen von doppeltem Code, das Aufteilen von Methoden in kleinere Einheiten, das Neuordnen von Vererbungshierarchien oder die Verwendung selbsterklärender Namen umfassen. Im Kontext von Solana könnte dieser Schritt bedeuten, die für eine Transaktion angeforderte Anzahl an CUs zu optimieren, die Zahl der CPIs zu reduzieren oder Transaktionsabläufe zu vereinfachen.

TDD ist zwar keine Voraussetzung für gute Solana-Programme, doch Entwickler sollten die zugrunde liegende Philosophie berücksichtigen. Sie fördert eine sorgfältige Programmentwicklung, schafft durch ihren iterativen Ansatz einen flexiblen und anpassungsfähigen Entwicklungsprozess und passt ideal zu den Anforderungen an Präzision, Sicherheit und Effizienz. TDD ermutigt Entwickler, saubereren, fokussierteren Code zu schreiben, der für die besonderen Netzwerk- und Performanceanforderungen von Solana optimiert ist, beispielsweise durch die Optimierung von CUs. 

Das Arrange-Act-Assert-Muster (AAA)

Das AAA-Muster bietet eine einfache, aber wirkungsvolle Struktur für klare, präzise und effektive Tests. Im Kern fördert es einen disziplinierten Ansatz, der das Schreiben von Tests in drei klar abgegrenzte Phasen unterteilt:

  • Arrange: Richte zunächst die Testumgebung ein und bereite alle relevanten Eingaben vor. Dazu können das Generieren von Accounts, das Simulieren von Account-Guthaben oder das Vorbereiten von Instructions gehören. Ziel ist ein kontrolliertes Szenario, das die Bedingungen nachbildet, unter denen du das Verhalten testest
  • Act: Führe das zu testende Verhalten aus. Im Mittelpunkt steht die Aktion, die dieses Verhalten auslöst. Was passiert beispielsweise, wenn ich Funktion x mit Account y aufrufe?
  • Assert: Vergleiche das Ergebnis der Aktion mit dem erwarteten Ergebnis. Dieser Schritt ist entscheidend, um festzustellen, ob der Test besteht oder fehlschlägt. Assertions können beispielsweise von einer einfachen Wertprüfung bis zu komplexen Validierungen mehrerer Zustandsänderungen reichen. Ihre konkrete Implementierung hängt letztlich vom verwendeten Framework oder Protokoll ab. Lighthouse ist ein Programm, das Assertion-Instructions bereitstellt. Diese können Transaktionen hinzugefügt werden, um beispielsweise unerwünschte Zustände, gefälschte Simulationsergebnisse oder überhöhte Ausgaben zu erkennen. Die Vorteile und Feinheiten von Lighthouse behandeln wir in einem eigenen Artikel ausführlicher.

Die Stärke des AAA-Musters liegt in seiner Anpassungsfähigkeit. Dadurch eignet es sich für Unit-, Integrations- und E2E-Tests. Zum Beispiel:

  • Unit-Tests: Ein Test für eine bestimmte Funktion könnte beim Arrange den Programmzustand einrichten, beim Act die Funktion aufrufen und beim Assert ihren Rückgabewert oder die resultierenden Zustandsänderungen prüfen
  • Integrationstests: Beim Testen der Interaktionen zwischen mehreren Programmen könnte Arrange das Deployment der Programme und das Festlegen ihrer Ausgangszustände umfassen. Beim Act werden die relevanten Transaktionen ausgeführt und beim Assert die Endzustände aller beteiligten Programme geprüft
  • E2E-Tests: Ein E2E-Test eines Programms könnte beim Arrange den Programmzustand einrichten, beim Act einen vollständigen erwarteten Nutzerablauf durchlaufen, beispielsweise einen Account erstellen, einen Vorschlag einreichen, darüber abstimmen und die Abstimmungsphase beenden, und beim Assert die Ergebnisse dieses Ablaufs mit den erwarteten Ergebnissen vergleichen

Das AAA-Muster ist für die Programmentwicklung entscheidend. Es erzwingt einen verhaltensorientierten Testansatz, der nötig ist, um zu prüfen, ob ein Programm wie vorgesehen funktioniert. Nach AAA strukturierte Tests sind leichter verständlich und wartbar, weil Einrichtung, Aktion und Prüfung klar voneinander getrennt sind. Außerdem fördert AAA unabhängige, entkoppelte Tests, die sich auf bestimmte Verhaltensweisen oder Interaktionen konzentrieren.

Best Practices der Branche

Gute Tests zu schreiben, ist keine Besonderheit der Solana-Entwicklung. Wir können allgemeine Erkenntnisse aus der Softwareentwicklung auf das Testen von Solana-Programmen übertragen. Dabei sollten wir das vorgesehene Verhalten testen, statt uns in Implementierungsdetails zu verlieren. 

Unit-Tests sollten beispielsweise in der Regel auf die öffentliche Schnittstelle einer Methode abzielen, ihr bestimmte Argumente übergeben und prüfen, ob sie die erwarteten Ergebnisse liefert. So bleiben Unit-Tests auch dann gültig, wenn sich die interne Implementierung der Methode ändert, solange ihr Verhalten gleich bleibt. Für die Solana-Entwicklung bedeutet das: Änderungen an der Programmlogik, die das externe Verhalten des Programms nicht beeinflussen, sollten kein Refactoring der Tests erfordern.

Ein häufiger Fehler bei Unit-Tests ist außerdem eine zu starke Abhängigkeit von den internen Abläufen der getesteten Methode. Dazu gehört etwa die Erwartung, dass bestimmte private Methoden eine bestimmte Anzahl von Malen aufgerufen oder auf eine bestimmte Weise implementiert werden. Solche Tests sind zu fragil und schlagen bei jedem Code-Refactoring leicht fehl, selbst wenn das tatsächliche Verhalten der getesteten Methode unverändert bleibt. Konzentriere dich stattdessen auf die extern beobachtbaren Ergebnisse und Seiteneffekte der Methode. Mit Werkzeugen zur Messung der Codeabdeckung kannst du sicherstellen, dass deine Tests umfassend sind, ohne übermäßig von internen Mechanismen abzuhängen.

Diese Best Practices machen Programme in der Solana-Entwicklung robuster und anpassungsfähiger. Wenn du Verhalten statt Implementierungsdetails testest, entsteht widerstandsfähigerer und besser wartbarer Code. Das ist unverzichtbar, wenn du Code in einer dynamischen Netzwerkumgebung wie Solana bereitstellst. Dieser Ansatz stellt sicher, dass Änderungen an der Programmlogik keine umfangreichen erneuten Tests erfordern und das Programm für das Deployment bereit ist.

Beginnen wir nun damit, einige Tests zu schreiben.

Grundlegende Unit-Tests schreiben

Unit-Tests in Rust

Rust verfolgt bei Unit-Tests einen besonderen Ansatz und empfiehlt Entwicklern, die Tests in denselben Dateien wie den zugehörigen Code abzulegen. Dies geschieht über das tests-Modul, das durch das #[cfg(test)]-Attribut aktiviert wird. Das Testattribut stellt sicher, dass diese Tests nur kompiliert und ausgeführt werden, wenn die Software explizit mit dem Befehl cargo test getestet wird. Mit dem Befehl cargo build werden sie also nicht ausgeführt. Entwickler können Tests außerdem mit dem Attribut #[ignore] vom regulären Testlauf ausschließen. Das ist bei besonders langsamen Tests nützlich und ermöglicht weiterhin ihre explizite Ausführung über den Befehl cargo test -- --ignored.

Betrachte als Beispiel die folgende Rust-Funktion:

Code
pub fn bubble_sort<T: Ord>(array: &mut [T]) {
    if array.is_empty() {
        return;
    }

    for i in 0..array.len() {
        for j in 0..array.len() - 1 - i {
            if array[j] > array[j + 1] {
                array.swap(j, j + 1);
            }
        }
    }
}

Bubblesort ist ein Sortieralgorithmus, der wiederholt die Elemente einer Liste durchläuft, das aktuelle Element mit dem nachfolgenden vergleicht und ihre Werte bei Bedarf vertauscht. Um zu prüfen, ob diese Funktion wie vorgesehen arbeitet, könnten wir folgende Tests schreiben:

Code
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_bubble_sort() {

        let mut test1 = vec![12, 39, 4, 36, 777];
        assert_eq!(bubble_sort(&mut test1), vec![4, 12, 36, 39, 777]);

        let mut test2 = vec![21, 55, 14, -123, 32, 0];
        assert_eq!(bubble_sort(&mut test2), vec![-123, 0, 14, 21, 32, 55]);

        let mut test3 = vec!["Orange", "Pear", "Apple", "Grape", "Banana"];
        assert_eq!(bubble_sort(&mut test3), vec!["Apple", "Banana", "Grape", "Orange", "Pear"]);
    }
}

Dieses Beispiel zeigt, wie unser Modul tests mit dem Attribut #[cfg(test)] annotiert wird. Innerhalb des Moduls importieren wir mit use super::*; alle öffentlichen Elemente des übergeordneten Moduls in den Gültigkeitsbereich des aktuellen Testmoduls. Anschließend folgen mehrere Testfälle, in denen wir prüfen, wie die sortierten Vektoren aussehen sollten. Rust bietet mehrere Makros für Assertions, darunter assert! für allgemeine Wahrheitswerte, assert_eq! für Gleichheit und assert_ne! für Ungleichheit. Diese Assertions bilden das Rückgrat der Teststrategie von Rust. Mehr brauchst du zunächst nicht, um Tests zu schreiben. 

Stell dir als sehr einfaches Beispiel vor, du hast eine Funktion, die ermittelt, ob ein Account über genügend Guthaben verfügt, um eine bestimmte Transaktion zu bezahlen:

Code
pub fn has_sufficient_balance(account_balance: u64, transaction_fee: u64) -> bool {
    account_balance >= transaction_fee
}

Diese Funktion erwartet zwei Argumente: das aktuelle Guthaben eines Accounts und die erwartete Transaktionsgebühr. Sie gibt true zurück, wenn das Guthaben ausreicht, um die Transaktionsgebühr zu decken, andernfalls false. Das lässt sich leicht mit folgendem Unit-Test prüfen:

Code
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn sufficient_funds_for_transaction() {
        let account_balance = 1_000_000;
        let transaction_fee = 5_000;

        assert!(has_sufficient_balance(account_balance, transaction_fee));
    }

    #[test]
    fn insufficient_funds_for_transaction() {
        let account_balance = 1_000;
        let transaction_fee = 5_000;

        assert!(!has_sufficient_balance(account_balance, transaction_fee));
    }
}

Im ersten Test prüfen wir, ob has_sufficient_balance den Wert true zurückgibt, wenn das Account-Guthaben deutlich über der Transaktionsgebühr liegt und somit genügend Mittel für die Transaktion vorhanden sind. Im zweiten Test prüfen wir, ob has_sufficient_funds den Wert false zurückgibt, wenn das Account-Guthaben unter der Transaktionsgebühr liegt und daher nicht ausreicht. 

Weitere wichtige Hinweise zu Tests in Rust

Rust bietet das Attribut #[should_panic], um Tests zu markieren, bei denen unter bestimmten Bedingungen ein Panic erwartet wird. Das ist nützlich, um Fehlerbehandlungspfade zu testen und erwartete Panic-Meldungen anzugeben:

Code
#[test]
#[should_panic(expected = "Divide-by-zero error")]
fn test_divide_by_zero() {
    divide_non_zero_result(0, 0);
}

Im Gegensatz zu vielen anderen Sprachen erlaubt Rust das direkte Testen privater Funktionen. Dadurch sind detailliertere Unit-Tests möglich, da sie jeden Aspekt der Codefunktionalität abdecken können.

Rust unterstützt außerdem fortgeschrittenere Techniken zur Organisation von Tests:

  • Verschachtelte Module: In komplexen Projekten lassen sich Tests in verschachtelten Modulen organisieren. Das ermöglicht eine klare hierarchische Struktur, die der Organisation des Projekts entspricht
  • Ergebnisbasierte Tests: Rust ermöglicht Tests, die einen Typ Result<(), E> zurückgeben. Dadurch können Entwickler den Operator ? in Tests verwenden und Fehler ausdrucksstärker behandeln

Unit-Tests in TypeScript mit Mocha und Chai

TypeScript hat sich als beliebte Wahl für Programmtests etabliert, da Anchor die Lingua franca der Rust-Entwicklung auf Solana ist. Beim Befehl anchor init werden das Test-Framework Mocha und die Assertion-Bibliothek Chai für neue Anchor-Projekte standardmäßig initialisiert. 

Mocha ist ein funktionsreiches JavaScript-Test-Framework, das auf Node.js ausgeführt wird. Dadurch sind asynchrone Tests sehr unkompliziert. In der Solana-Entwicklung dient Mocha hauptsächlich dazu, die clientseitige Logik von dApps und andere Blockchain-Interaktionen zu testen. 

Chai ist eine Assertion-Bibliothek, die sich mit jedem JavaScript-Test-Framework wie Mocha kombinieren lässt. Sie bietet Entwicklern verschiedene Funktionen, mit denen sie Assertions lesbar formulieren können. Mit den Schnittstellen expect, should und assert von Chai können Entwickler umfassende Tests schreiben, die intuitiv zu lesen und zu verfassen sind. Für bessere Lesbarkeit der Assertions verwendet Chai bei den Schnittstellen expect und should Sprachketten, also verkettbare Getter. Dank Chai ist expect({a: 1, b: 2}).to.not.have.any.keys(“c”, “d”); eine sehr gut lesbare und gültige Assertion.

Wenn wir beispielsweise mit dem Befehl anchor init hello_world ein Projekt hello_world erstellen, wird im Verzeichnis hello_world/tests die folgende Testdatei hello_world.ts angelegt:

Code
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { HelloWorld } from "../target/types/hello_world";

describe("hello_world", () => {
  // Configure the client to use the local cluster.
  anchor.setProvider(anchor.AnchorProvider.env());

  const program = anchor.workspace.HelloWorld as Program<HelloWorld>;

  it("Is initialized!", async () => {
    // Add your test here.
    const tx = await program.methods.initialize().rpc();
    console.log("Your transaction signature", tx);
  });
});

Sehen wir uns an, was das alles bedeutet.

Mocha verwendet Blöcke vom Typ describe, um Tests zu gruppieren, und Funktionen vom Typ it, um Testfälle zu definieren. Dieses Beispiel folgt dem AAA-Muster und bietet damit einen strukturierten Testansatz:

  • Arrange: Hier konfiguriert anchor.setProvider(anchor.AnchorProvider.env()); den Anchor-Client so, dass er den Standard-Provider der Umgebung verwendet, der normalerweise auf einen lokalen Solana-Test-Validator verweist. Anschließend initialisiert die const-Deklaration program eine Instanz des zu testenden Programms, sodass wir seine Methoden im Test aufrufen können
  • Act: In unserem Testfall “Is initialized!” rufen wir die Methode initialize unseres Programms auf und senden die Transaktion
  • Assert: In diesem Testfall protokollieren wir die Transaktionssignatur, ohne eine Assertion anzugeben. Normalerweise würden wir in dieser Phase Chai für Assertions einsetzen. Als sehr einfaches Beispiel könnten wir den standardmäßigen Testcode um eine Assertion wie expect(tx).to.be.a(“string”); ergänzen. Ein detaillierterer Test könnte nach der Initialisierung den Programmzustand abrufen und untersuchen und dann prüfen, ob er den erwarteten Werten entspricht

Die Kombination aus Mocha und Chai sowie dem standardmäßig in Anchor-Projekten konfigurierten AAA-Muster bietet ein robustes Framework zum Testen von Programmen. Indem Solana-Entwickler die Testumgebung klar einrichten, Programmmethoden ausführen und die Ergebnisse prüfen, können sie sicherstellen, dass Programme vorhersehbar und zuverlässig funktionieren.

Stell dir beispielsweise vor, du entwickelst ein Solana-Programm, mit dem Nutzer SOL in einen Vault einzahlen und daraus abheben können. So könnte ein in TypeScript mit Mocha und Chai geschriebener Test aussehen, der prüft, ob die Einzahlungsfunktion wie erwartet arbeitet:

Code
import { expect } from "chai";
import { PublicKey } from "@solana/web3.js";
import { depositSOL } from "../src/vault";

describe("Vault Program", function() {
    describe("Deposit functionality", function() {
        it("should correctly deposit SOL into the vault", async function() {
            const vaultPublicKey = new PublicKey(/* vault public key */);
            const userPublicKey = new PublicKey(/* user public key */);
            const depositAmount = 1; // 1 SOL

            const initialVaultBalance = await getVaultBalance(vaultPublicKey);
            await depositSOL(vaultPublicKey, userPublicKey, depositAmount);

            const finalVaultBalance = await getVaultBalance(vaultPublicKey);
            expect(finalVaultBalance).to.equal(initialVaultBalance + depositAmount);
        });
    });
});

Dieses Beispiel testet eine hypothetische Funktion depositSOL, die das Einzahlen von SOL in einen Vault verarbeitet. Es prüft, ob sich das Guthaben des Vaults nach der Einzahlung um den richtigen Betrag erhöht. Wir verwenden die Funktion getVaultBalance, eine angenommene Hilfsfunktion, die das aktuelle Guthaben des Vaults abruft.

Weitere wichtige Hinweise zu Tests in TypeScript mit Mocha und Chai

Das statische Typsystem von TypeScript kann das Schreiben von Tests manchmal erschweren, insbesondere bei komplexen oder unzureichend definierten Typen. Verwende Type Assertions, um typbezogene Probleme in deinen Tests zu vermeiden. Achte jedoch darauf, dass diese Assertions keine potenziellen Laufzeitfehler verdecken, die durch falsche Typen entstehen könnten.

Wenn du Objekte oder Funktionen in TypeScript mockst, musst du sicherstellen, dass die gemockten Entitäten den richtigen Typen entsprechen. Bibliotheken wie ts-sinon oder ts-mockito helfen dabei, typsichere Mocks zu erstellen. So bleiben Tests präzise und bilden das reale Verhalten des Programms ab.

Mocha stellt die Methoden only und skip bereit, um ausschließlich bestimmte Tests auszuführen oder einzelne Tests zu überspringen. Das ist während der Entwicklung praktisch, kann aber leicht versehentlich in die Produktion gelangen und zu unvollständigen Testläufen führen. Prüfe Tests daher immer auf only oder skip, bevor du sie in die Produktion überführst. Sei außerdem vorsichtig, wenn du Mochas Hooks, also beforeEach, afterEach, before und after, mit asynchronem Code verwendest. Stelle sicher, dass Promises mit async/await korrekt verarbeitet werden, oder rufe die Callback-Methode done auf, um nicht aufgelöste Promises oder nicht aufgerufene Callbacks zu vermeiden.

Beachte bei der Verwendung von Chais expect().to.deep.equal() dessen Verhalten bei Objekten mit dynamisch generierten Eigenschaften wie Datumsangaben oder Zufallswerten. Diese Eigenschaften können bei Tests, die tiefe Gleichheit erwarten, unerwartete Fehler verursachen. Verwende nach Möglichkeit Chais expect().to.include() für gezieltere Assertions.

Beliebte Solana-Testframeworks

Bankrun

Eine Bank überwacht Client-Accounts, verwaltet die Programmausführung und gewährleistet die Integrität und Fortschreibung des Solana-Ledgers. Sie ist im Wesentlichen eine Momentaufnahme des Ledgers zu einem bestimmten Zeitpunkt und bildet den Zustand ab, der aus den Transaktionen eines bestimmten Blocks resultiert. 

Bankrun ist ein schlankes, flexibles und in Node.js geschriebenes Testframework für Solana-Programme. Es setzt auf einfache Bedienung und Geschwindigkeit, sodass Entwickler schnell Tests für ihre Programme schreiben und ausführen können. Der eigentliche Mehrwert von Bankrun liegt darin, dass es Entwicklern als Testframework ermöglicht, Solana-Banks in einer kontrollierten, effizienten Umgebung zu simulieren und mit ihnen zu interagieren. Bankrun vereinfacht den Testprozess, indem es die Dynamik von Solana-Banks nachbildet, ohne den üblichen Aufwand für die Einrichtung einer solchen Umgebung.

Das Design von Bankrun basiert auf einem schlanken BanksServer, der das Verhalten eines RPC-Nodes nachbildet, aber deutlich mehr Leistung und Flexibilität bietet. Entwickler können über den BanksClient mit diesem Server interagieren. Dieser Client stellt ein umfangreiches Toolkit mit Methoden bereit, um Account-Guthaben und Transaktionsstatus abzurufen sowie Transaktionen zu simulieren. Besonders hilfreich ist die Methode tryProcessTransaction: Sie verarbeitet Transaktionen, die erwartungsgemäß fehlschlagen, ohne JavaScript-Fehler auszulösen. So können Entwickler bestimmte Fehlermodi oder Log-Meldungen direkt prüfen.

Das Futarchy-GitHub-Repository von Meta-DAO zeigt hervorragend, wie sich produktionsreifer Code mit Bankrun testen lässt.

Integration mit Anchor

Bankrun lässt sich sehr einfach in Anchor integrieren. Mit startAnchor können Entwickler automatisch alle Programme aus einem Anchor-Workspace in der Testumgebung bereitstellen. Dadurch bilden Tests das Verhalten des Programms in einer vollständigen Solana-Umgebung präzise ab. Die Bankrun-Dokumentation enthält folgendes Codebeispiel:

Code
import { startAnchor } from "solana-bankrun";
import { PublicKey } from "@solana/web3.js";

test("anchor", async () => {
	const context = await startAnchor("tests/anchor-example", [], []);
	const programId = new PublicKey(
		"Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS",
	);
	const executableAccount = await context.banksClient.getAccount(programId);
	expect(executableAccount).not.toBeNull();
	expect(executableAccount?.executable).toBe(true);
});

Das Paket anchor-bankrun ist eine leistungsstarke Erweiterung für Anchor und Bankrun. Es exportiert eine BankrunProvider-Klasse, die beim Testen als direkter Ersatz für AnchorProvider verwendet werden kann.

Beliebige Accounts mit Bankrun schreiben

Eine herausragende Funktion von Bankrun ist die Möglichkeit, beliebige Account-Daten zu schreiben. Dadurch können Entwickler die Einschränkungen von Account-Zuständen umgehen und erhalten ein bislang unerreichtes Maß an Flexibilität. Ein Entwickler kann beispielsweise einen Account mit einer großen Menge USDC simulieren, ohne das Keypair des USDC-Mints zu besitzen. Das ist für Tests äußerst wertvoll, da keine echten Token manipuliert werden müssen und sich komplexe Szenarien schneller einrichten lassen. 

Die Bankrun-Dokumentation enthält ein Codebeispiel für einen unbegrenzten USDC-Mint, das diese Funktion anhand der Funktion start zeigt. Die Funktion start bereitet die Testumgebung vor, indem sie Programme bereitstellt und die Account-Daten wie angegeben festlegt.

Zeitreisen

Eine weitere eigenständige Funktion ist die Möglichkeit, mit Bankrun durch die Zeit zu reisen, also das Zeitkonzept für Testzwecke zu manipulieren. Entwickler können die Uhr des Solana-Clusters vor- oder zurückstellen, also die Sysvar Clock, und so bestimmte zeitliche Bedingungen sofort simulieren. Diese Funktion ist entscheidend, um Programme mit zeitbasierter Logik zu testen, etwa Vesting-Zeitpläne, Token-Sperren oder Funktionen, die beim Erreichen eines bestimmten Zeitpunkts ausgelöst werden.

Zeitreisen sind dank der Methode setClock unkompliziert. Mit dieser Methode können Entwickler die aktuelle Zeit des Clusters auf einen vordefinierten Unix-Zeitstempel setzen und die gesamte Testumgebung so in die Vergangenheit oder Zukunft verschieben. Operationen und Transaktionen im Test laufen weiter, als wäre der angegebene Zeitpunkt die Gegenwart. Dadurch lässt sich das Verhalten des Programms unter diesen Bedingungen präzise beurteilen.

Dieses sehr einfache Beispiel zeigt, wie Zeitreisen mit Bankrun funktionieren:

Code
import { start } from "solana-bankrun";
import { PublicKey, Transaction, SystemProgram } from "@solana/web3.js";

async function simulateTimeTravel(context, secondsForward) {
    const newTimestamp = context.clock.unixTimestamp + secondsForward;
    context.adjustClock(newTimestamp);
}

test("One Year Later...", async () => {
    const context = await start([], []);
    const { banksClient, payer } = context;

    // Simulate setting the cluster clock forward by one year (in seconds)
    const oneYearInSeconds = 365 * 24 * 60 * 60;
    await simulateTimeTravel(context, oneYearInSeconds);

    // Proceed with tests assuming the future time
    const transaction = new Transaction().add(
        SystemProgram.transfer({
            fromPubkey: payer.publicKey,
            toPubkey: PublicKey.unique(),
            lamports: 100,
        }),
    );

    transaction.recentBlockhash = context.lastBlockhash;
    transaction.sign(payer);

    await banksClient.processTransaction(transaction);

    // Add assertions here to test expected future behavior
});

Bankrun vs. solana-test-validator

Die Wahl zwischen Bankrun und solana-test-validator hängt weitgehend von den konkreten Anforderungen der Testszenarien ab. Dank seiner Geschwindigkeit, Flexibilität und spezialisierten Funktionen ist Bankrun für die meisten Entwicklungsszenarien die bevorzugte Wahl. Das gilt besonders, wenn schnelle Iterationen oder detaillierte Simulationen nötig sind. solana-test-validator bleibt jedoch relevant für Tests, die vom realen Verhalten eines Validators oder von RPC-Methoden abhängen, die BanksServer nicht unterstützt.

solana-program-test

Die Crate solana-program-test bietet ein Rust-basiertes Testframework, das speziell für Solana-Programme entwickelt wurde. Im Mittelpunkt dieses Frameworks steht der BanksClient. Er simuliert die Abläufe einer Solana-Bank und ermöglicht Entwicklern, ihre Programme bereitzustellen, mit ihnen zu interagieren und ihr Verhalten unter mainnetähnlichen Testbedingungen zu bewerten – ähnlich wie bei Bankrun. Ergänzt wird der BanksClient durch die Struktur ProgramTest, ein Hilfsmittel zum Initialisieren der Testumgebung. Sie erleichtert also die Entwicklung der angegebenen Programme und das Einrichten der erforderlichen Accounts. Weitere Strukturen wie BanksTransactionResultWithMetadata, InvokeContext und ProgramTestContext liefern umfassende Einblicke und Kontext zu den während des Tests verarbeiteten Transaktionen. Das verbessert den gesamten Debugging- und Verifizierungsprozess. 

Um die lokale Entwicklung und Tests zu vereinfachen, lädt solana-program-test automatisch mehrere Programme vor:

  • SPL Token (und seine Version von 2022)
  • SPL Memo (Versionen 1.0 und 3.0)
  • SPL Associated Token Account

Diese vorinstallierten Programme ermöglichen eine schnellere und gezieltere Testeinrichtung, da diese gängigen Programme nicht manuell eingerichtet werden müssen.

Das Marginfi-GitHub-Repository enthält mehrere hervorragende Beispiele für die Implementierung von solana-program-test in produktionsreifem Code. Auch der Entwicklungsleitfaden von Bonfida bietet eine ausführliche Anleitung zum Schreiben von Integrationstests mit dem Framework solana-program-test.

solana-test-framework

solana-test-framework ist eine von Halborn entwickelte Erweiterung von solana-program-test. Es erweitert BanksClient, RpcClient, ProgramTest und ProgramTestContext um mehrere praktische Methoden und verbessert so die Testumgebung. Wie bei Bankrun ermöglichen beispielsweise die Erweiterungen von ProgramTestContext anspruchsvolle Testszenarien, in denen Entwickler zu bestimmten Zeitstempeln springen und Oracle-Preise aktualisieren können.

Diese Erweiterungen bieten folgende Verbesserungen:

  • Transaktionsverwaltung: Vereinfacht das Zusammenstellen, Signieren und Bezahlen von Transaktionen über transaction_from_instructions
  • Deserialisierung von Accounts: Erleichtert das Abrufen und Deserialisieren von Anchor- beziehungsweise Borsh-Accounts mit get_account_with_anchor and get_account_with_borsh
  • Erstellung von Accounts und Bereitstellung von Programmen: Ermöglicht Entwicklern, ihre Testumgebung mit Funktionen wie create_account, create_token_mint, create_token_account und deploy_program effizient einzurichten.

solana-test-framework  unterstützt sowohl externe Cluster als auch eine simulierte Laufzeitumgebung. Es ist mit mehreren Versionen von Solana und Anchor kompatibel, darunter die Solana-Versionen 1.9 bis 1.14 sowie die entsprechenden Anchor-Versionen für 1.9, 1.10 und 1.14. 

Beispiel für ein Testszenario

Das Programm

Nimm das folgende Programm als Beispiel:

Code
use anchor_lang::prelude::*;
use anchor_lang::solana_program::system_instruction;
use solana_program::program::invoke;

declare_id!("3vMZa7r3CpHGejvXYbUpPXmm54FxCDPF1QAYnnzL88J9");

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

    pub fn initialize(ctx: Context<Initialize>, initial_prize: u64) -> Result<()> {
        // In case the person who went first didn't send any SOL as the initial prize
        require!(initial_prize > 0, ErrorCode::NeedAnInitialPrize);

        let game_state = &mut ctx.accounts.game_state;

        game_state.king = ctx.accounts.initial_king.key();
        game_state.prize = initial_prize;

        let transfer_instruction = system_instruction::transfer(
            &ctx.accounts.initial_king.key(),
            &ctx.accounts.prize_pool.key(),
            initial_prize,
        );

        invoke(
            &transfer_instruction,
            &[
                ctx.accounts.initial_king.to_account_info(),
                ctx.accounts.prize_pool.to_account_info(),
                ctx.accounts.system_program.to_account_info(),
            ],
        )?;

        Ok(())
    }

    pub fn become_king(ctx: Context<BecomeKing>, new_prize: u64) -> Result<()> {
        require!(
            new_prize > ctx.accounts.game_state.prize,
            ErrorCode::BidTooLow
        );

        let transfer_to_pool_instruction = system_instruction::transfer(
            &ctx.accounts.payer.key(),
            &ctx.accounts.prize_pool.key(),
            new_prize,
        );

        // Send the new king's funds to the pool
        invoke(
            &transfer_to_pool_instruction,
            &[
                ctx.accounts.payer.to_account_info(),
                ctx.accounts.prize_pool.to_account_info(),
                ctx.accounts.system_program.to_account_info(),
            ],
        )?;

        // Send the old king's funds back
        ctx.accounts.prize_pool.sub_lamports(ctx.accounts.game_state.prize);
        ctx.accounts.king.add_lamports(ctx.accounts.game_state.prize);

        ctx.accounts.game_state.king = ctx.accounts.payer.key();
        ctx.accounts.game_state.prize = new_prize;

        Ok(())
    }
}

#[derive(Accounts)]
pub struct Initialize<'info> {
    #[account(
        init,
        payer = initial_king,
        space = 8 + 32 + 8 + 1,
        seeds = [b"game_state"],
        bump,
    )]
    pub game_state: Account<'info, GameState>,
    #[account(mut)]
    pub initial_king: Signer<'info>,
    #[account(
        init,
        payer = initial_king,
        space = 8 + 8,
        seeds = [b"prize_pool"],
        bump,
    )]
    /// CHECK: This is okay - it's a PDA to store SOL and doesn't need a data layout
    pub prize_pool: UncheckedAccount<'info>,
    pub system_program: Program<'info, System>,
}

#[derive(Accounts)]
pub struct BecomeKing<'info> {
    #[account(
        mut,
        has_one = king,
    )]
    pub game_state: Account<'info, GameState>,
    #[account(mut)]
    /// CHECK: This is okay - it's only receiving SOL and we don't need any other access
    pub king: UncheckedAccount<'info>,
    #[account(mut)]
    pub payer: Signer<'info>,
    #[account(
        mut,
        seeds = [b"prize_pool"],
        bump,
    )]
    /// CHECK: This is okay - it's a PDA to store SOL and doesn't need a data layout
    pub prize_pool: UncheckedAccount<'info>,
    pub system_program: Program<'info, System>,
}

#[account]
pub struct GameState {
    pub king: Pubkey,
    pub prize: u64,
    pub prize_pool_bump: u8,
}

#[error_code]
pub enum ErrorCode {
    #[msg("The initial prize must be greater than zero")]
    NeedAnInitialPrize,
    #[msg("The bid must be higher than the current prize")]
    BidTooLow,
    #[msg("Invalid prize pool account")]
    InvalidPrizePoolAccount,
}

Dieses Programm implementiert ein einfaches „King of the Hill“-Spiel auf Solana. Benutzer können zum „König“ werden, indem sie mehr SOL an den Preispool senden als der aktuelle König. Sobald ein neuer König seinen Platz einnimmt, erhält der vorherige König die von ihm gesendeten SOL zurück.

Das Programm funktioniert wie folgt:

  • Initialisieren: Diese Funktion richtet das Spiel mit einem ersten König ein, also dem ersten Spieler, der das Spiel initialisiert, und legt einen anfänglichen Preisbetrag fest. Der anfängliche Preis muss größer als null sein. Anschließend überträgt die Funktion den anfänglichen Preis vom ersten König an einen Preispool
  • König werden: Mit dieser Funktion kann ein neuer Spieler zum König werden, indem er mehr SOL als den aktuellen Preis bietet. Sie überträgt den aktuellen Preis an den abgelösten König und aktualisiert den Preispool mit dem Gebot des neuen Königs, der dadurch zum König wird. Dieses Gebot muss höher als der aktuelle Preis sein

Tests schreiben

Mit dem folgenden Code können wir das King-of-the-Hill-Spiel erfolgreich testen:

Code
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { KingOfTheHill } from "../target/types/king_of_the_hill";

import { assert } from "chai";

const web3 = require("@solana/web3.js");

describe("King of the Hill Tests", () => {
  // Configure the client to use the local cluster.
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  const program = anchor.workspace.KingOfTheHill
as Program<KingOfTheHill>;

  let initialKing, newKing;
  let gameStatePDA, prizePoolPDA;

  // Utility function for airdrops
  async function fundWallet(account, amount) {
    const publicKey = account.publicKey ? account.publicKey : account;

    await provider.connection.confirmTransaction(
      await provider.connection.requestAirdrop(publicKey, amount),
      "confirmed"
    );
  }

  before(async () => {
    initialKing = web3.Keypair.generate();
    newKing = web3.Keypair.generate();

    await fundWallet(initialKing, 25 * web3.LAMPORTS_PER_SOL);
    await fundWallet(newKing, 30 * web3.LAMPORTS_PER_SOL);

    [gameStatePDA] = web3.PublicKey.findProgramAddressSync(
      [Buffer.from("game_state")],
      program.programId
    );

    [prizePoolPDA] = web3.PublicKey.findProgramAddressSync(
      [Buffer.from("prize_pool")],
      program.programId
    );
  });

  it("Initializes the game correctly", async () => {
    // Arrange
    await fundWallet(gameStatePDA, 1 * web3.LAMPORTS_PER_SOL);
    await fundWallet(prizePoolPDA, 1 * web3.LAMPORTS_PER_SOL);

    let initialPrize = new anchor.BN(1 * web3.LAMPORTS_PER_SOL);

    // Act
    const tx = await program.methods
      .initialize(initialPrize)
      .accounts({
        gameState: gameStatePDA,
        initialKing: initialKing.publicKey,
        prizePool: prizePoolPDA,
        systemProgram: web3.SystemProgram.programId,
      })
      .signers([initialKing])
      .rpc();

    // Assert
    let gameState: any = await program.account.gameState.fetch(gameStatePDA);
    assert.equal(gameState.king.toBase58(), initialKing.publicKey.toBase58());
    assert.equal(
      gameState.prize.toString(),
      new anchor.BN(1 * web3.LAMPORTS_PER_SOL).toString()
    );
  });

  it("Changes the king correctly", async () => {
    // Arrange
    const initialKingBalanceBefore = await provider.connection.getBalance(initialKing.publicKey);
    let newPrize = new anchor.BN(2 * web3.LAMPORTS_PER_SOL);

    // Act
    const becomeKingTx = await program.methods.becomeKing(newPrize)
      .accounts({
          gameState: gameStatePDA,
          king: initialKing.publicKey, // Correct usage of current king
          payer: newKing.publicKey, // New king who pays and becomes the king
          prizePool: prizePoolPDA,
          systemProgram: web3.SystemProgram.programId,
      })
      .signers([newKing]) // Signing by newKing
      .rpc();

    // Assert
    const initialKingBalanceAfter = await provider.connection.getBalance(initialKing.publicKey);

    const expectedBalance = initialKingBalanceBefore + new anchor.BN(1 * web3.LAMPORTS_PER_SOL).toNumber();
    assert.ok(initialKingBalanceAfter >= expectedBalance, "Old king did not receive the funds back correctly");

    // Fetch the updated game state.
    const updatedGameState = await program.account.gameState.fetch(gameStatePDA);

    // Assertions to confirm the state has updated as expected.
    assert.equal(updatedGameState.king.toBase58(), newKing.publicKey.toBase58(), "King should be updated to newKing.");
    assert.equal(updatedGameState.prize.toString(), newPrize.toString(), "Prize should be updated to newPrize.");
  })
});

Sehen wir uns alles Schritt für Schritt an.

Zuerst importieren wir die benötigten Komponenten und richten unsere Testumgebung in Anchor ein. Für dieses Beispiel teste ich mit TypeScript, Mocha und Chai auf localhost:

Code
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { KingOfTheHill } from "../target/types/king_of_the_hill";

import { assert } from "chai";

const web3 = require("@solana/web3.js");

Wir verwenden describe, um unsere Testfälle zu gruppieren. Außerdem konfigurieren wir den Client für den lokalen Cluster, setzen das Programm korrekt, initialisieren Variablen für den ersten König, den neuen König, die PDA des Spielzustands beziehungsweise die PDA des Preispools und erstellen eine Hilfsfunktion, die Airdrops vereinfacht:

Code
describe("King of the Hill Tests", () => {
  // Configure the client to use the local cluster.
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  const program = anchor.workspace.KingOfTheHill as Program<KingOfTheHill>;

  let initialKing, newKing;
  let gameStatePDA, prizePoolPDA;

  // Utility function for airdrops
  async function fundWallet(account, amount) {
    const publicKey = account.publicKey ? account.publicKey : account;

    await provider.connection.confirmTransaction(
      await provider.connection.requestAirdrop(publicKey, amount),
      "confirmed"
    );
  }

// Other code

});

Als Nächstes verwenden wir den before-Hook, um die Keypairs des ersten und des neuen Königs einzurichten und zu finanzieren. Außerdem leiten wir die PDAs für den Spielzustand und den Preispool ab. Dieser Block wird einmal vor den Testfällen ausgeführt. So können wir jeden Fall deutlicher nach dem AAA-Muster strukturieren:

Code
before(async () => {
    initialKing = web3.Keypair.generate();
    newKing = web3.Keypair.generate();

    await fundWallet(initialKing, 25 * web3.LAMPORTS_PER_SOL);
    await fundWallet(newKing, 30 * web3.LAMPORTS_PER_SOL);

    [gameStatePDA] = web3.PublicKey.findProgramAddressSync(
      [Buffer.from("game_state")],
      program.programId
    );

    [prizePoolPDA] = web3.PublicKey.findProgramAddressSync(
      [Buffer.from("prize_pool")],
      program.programId
    );
});

Der erste Testfall ist sehr einfach: Wir prüfen, ob unser Spiel korrekt initialisiert wird. In der Vorbereitungsphase finanzieren wir die PDAs des Spielzustands und des Preispools, damit wir später mit ihnen interagieren können, und setzen den anfänglichen Preis auf 1 SOL. Anschließend rufen wir die initialize-Methode mit initialPrize auf. Als Accounts übergeben wir die PDA des Spielzustands, den ersten König, die PDA des Preispools und das Systemprogramm. Der erste König signiert diese Aktion. Danach prüfen wir, ob der Spielzustand den König und den Preis korrekt aktualisiert hat:

Code
it("Initializes the game correctly", async () => {
    // Arrange
    await fundWallet(gameStatePDA, 1 * web3.LAMPORTS_PER_SOL);
    await fundWallet(prizePoolPDA, 1 * web3.LAMPORTS_PER_SOL);

    let initialPrize = new anchor.BN(1 * web3.LAMPORTS_PER_SOL);

    // Act
    const tx = await program.methods
      .initialize(initialPrize)
      .accounts({
        gameState: gameStatePDA,
        initialKing: initialKing.publicKey,
        prizePool: prizePoolPDA,
        systemProgram: web3.SystemProgram.programId,
      })
      .signers([initialKing])
      .rpc();

    // Assert
    let gameState: any = await program.account.gameState.fetch(gameStatePDA);
    assert.equal(gameState.king.toBase58(), initialKing.publicKey.toBase58());
    assert.equal(
      gameState.prize.toString(),
      new anchor.BN(1 * web3.LAMPORTS_PER_SOL).toString()
    );
});

Der nächste Testfall stellt sicher, dass eine andere Person zum König werden kann. In der Vorbereitungsphase rufen wir das anfängliche Guthaben des Königs ab und setzen den neuen Preis auf 2 SOL. Dann rufen wir die Funktion becomeKing auf und übergeben den neuesten Preisbetrag. Als Accounts übergeben wir die PDA des Spielzustands, den öffentlichen Schlüssel des aktuellen Königs, den neuen König als Zahler, die PDA des Preispools und das Systemprogramm. Der neue König wird als Signierer festgelegt. Abschließend prüfen wir, ob der König die ursprünglich zum Erlangen der Königswürde beigesteuerten SOL zurückerhält und ob der Spielzustand korrekt aktualisiert wurde:

Code
it("Changes the king correctly", async () => {
    // Arrange
    const initialKingBalanceBefore = await provider.connection.getBalance(initialKing.publicKey);
    let newPrize = new anchor.BN(2 * web3.LAMPORTS_PER_SOL);

    // Act
    const becomeKingTx = await program.methods.becomeKing(newPrize)
      .accounts({
          gameState: gameStatePDA,
          king: initialKing.publicKey, // Correct usage of current king
          payer: newKing.publicKey, // New king who pays and becomes the king
          prizePool: prizePoolPDA,
          systemProgram: web3.SystemProgram.programId,
      })
      .signers([newKing]) // Signing by newKing
      .rpc();

    // Assert
    const initialKingBalanceAfter = await provider.connection.getBalance(initialKing.publicKey);

    const expectedBalance = initialKingBalanceBefore + new anchor.BN(1 * web3.LAMPORTS_PER_SOL).toNumber();
    assert.ok(initialKingBalanceAfter >= expectedBalance, "Old king did not receive the funds back correctly");

    // Fetch the updated game state.
    const updatedGameState = await program.account.gameState.fetch(gameStatePDA);

    // Assertions to confirm the state has updated as expected.
    assert.equal(updatedGameState.king.toBase58(), newKing.publicKey.toBase58(), "King should be updated to newKing.");
    assert.equal(updatedGameState.prize.toString(), newPrize.toString(), "Prize should be updated to newPrize.");
})

In diesem Testszenario haben wir die Kernfunktionen unseres King-of-the-Hill-Programms untersucht. Mit Unit-Tests haben wir die Integrität der Programmlogik auf granularer Ebene validiert. Indem wir den korrekten Wechsel des Königs getestet haben, konnten wir außerdem bestätigen, dass das Programm unter simulierten realen Bedingungen wie vorgesehen funktioniert. Diese Tests verdeutlichen, wie wichtig eine umfassende Teststrategie für die Qualität und Funktionalität unseres King-of-the-Hill-Programms ist. 

Fazit

Tests sind der Grundstein für die Entwicklung sicherer, zuverlässiger und effizienter Solana-Programme. In diesem Artikel haben wir untersucht, warum Unit-, Integrations- und E2E-Tests kombiniert werden sollten, um alle Bereiche des Programmentwicklungszyklus abzudecken. Durch die Kombination dieser Methoden und den Einsatz leistungsstarker Testframeworks wie Bankrun, solana-program-test und solana-test-framework können Entwickler die Qualität ihrer Solana-Programme deutlich verbessern. Nutze auf deinem weiteren Weg als Solana-Entwickler die hier vorgestellten Prinzipien, Praktiken und Beispiele, um robuste, effiziente und sichere Programme zu entwickeln.

Wenn du bis hierher gelesen hast: Danke, Anon! Trag unten deine E-Mail-Adresse ein, damit du keine Neuigkeiten rund um Solana verpasst. Bereit, tiefer einzusteigen? Entdecke noch heute die neuesten Artikel im Helius-Blog und setze deine Solana-Reise fort.

Weitere Ressourcen

Helius abonnieren

Bleib bei der Solana-Entwicklung auf dem Laufenden und erhalte Updates, wenn wir neue Beiträge veröffentlichen

Vergrößertes Bild