Testowanie większych kontraktów lub systemów o złożonej logice w Solidity wymaga szczególnej uwagi, ponieważ zwiększona liczba funkcji i zależności między nimi podnosi ryzyko błędów. Aby upewnić się, że kontrakt działa poprawnie i bezpiecznie, należy przestrzegać poniższych zasad i podejść do testowania.
1. Testy Jednostkowe (Unit Testing)
Testy jednostkowe sprawdzają pojedyncze funkcje lub małe fragmenty logiki kontraktu, izolując je od innych części systemu. Dla dużych kontraktów testy jednostkowe są fundamentem, ponieważ pozwalają upewnić się, że poszczególne elementy działają poprawnie.
- Pokrycie wszystkich funkcji: Upewnij się, że każda funkcja ma przypisane testy jednostkowe, które pokrywają różne scenariusze, w tym scenariusze błędów.
- Izolacja testów: Testy jednostkowe powinny być tak napisane, aby sprawdzały działanie pojedynczej funkcji bez wpływu innych funkcji i stanu kontraktu.
- Testy krawędziowe: Sprawdź, czy funkcje działają poprawnie przy wartości granicznych (np. minimalnych i maksymalnych wartościach liczbowych).
Przykład testu jednostkowego:
const ComplexContract = artifacts.require("ComplexContract");
contract("ComplexContract", accounts => {
it("should calculate correct result for add function", async () => {
const instance = await ComplexContract.deployed();
const result = await instance.add(2, 3);
assert.equal(result.toNumber(), 5, "Addition result is incorrect");
});
});
2. Testy Integracyjne
Testy integracyjne sprawdzają współpracę między funkcjami oraz interakcje między kontraktami. W przypadku dużych kontraktów często istnieją zależności między różnymi modułami, dlatego ważne jest, aby sprawdzić, czy działają one poprawnie razem.
- Sprawdzanie przepływu danych: Przeprowadź testy, które sprawdzą, jak dane przepływają między różnymi funkcjami i czy interakcje między nimi prowadzą do poprawnych wyników.
- Testy sekwencyjne: Sprawdź, czy funkcje, które są wykonywane w określonej kolejności, działają zgodnie z oczekiwaniami (np. sprawdzenie sekwencji rejestracji, zatwierdzenia i przesyłania środków).
- Interakcje między kontraktami: Jeśli system wykorzystuje kilka kontraktów, upewnij się, że działają one razem zgodnie z założeniami.
Przykład testu integracyjnego:
const Token = artifacts.require("Token");
const Crowdsale = artifacts.require("Crowdsale");
contract("Crowdsale", accounts => {
it("should transfer tokens after purchase", async () => {
const token = await Token.deployed();
const crowdsale = await Crowdsale.deployed();
// Użytkownik kupuje tokeny za 1 ETH
await crowdsale.buyTokens(accounts[1], { value: web3.utils.toWei("1", "ether") });
// Sprawdzanie, czy użytkownik otrzymał tokeny
const balance = await token.balanceOf(accounts[1]);
assert.isAbove(balance.toNumber(), 0, "Tokens were not transferred");
});
});
3. Testy Graniczne i Negatywne
Testy graniczne i negatywne są kluczowe dla dużych kontraktów, ponieważ pozwalają sprawdzić, jak system reaguje na nieoczekiwane wartości i sytuacje.
- Testy błędów: Sprawdź, czy kontrakt odrzuca transakcje w sytuacjach, w których nie powinien działać (np. niewystarczająca liczba środków, nieprawidłowe uprawnienia).
- Testy odporności na dane wejściowe: Sprawdź, jak funkcje reagują na nietypowe wartości (np. wartości ujemne, zbyt duże liczby).
- Testy granic złożoności: Dla systemów o złożonej logice testuj, czy kontrakt poprawnie obsługuje maksymalną liczbę iteracji w pętlach, największe możliwe wartości liczbowe itp.
Przykład testu negatywnego:
const Bank = artifacts.require("Bank");
contract("Bank", accounts => {
it("should revert when withdrawing more than balance", async () => {
const bank = await Bank.deployed();
// Próba wycofania większej liczby środków niż dostępne
try {
await bank.withdraw(web3.utils.toWei("10", "ether"), { from: accounts[1] });
assert.fail("Withdrawal should have reverted");
} catch (error) {
assert.include(error.message, "revert", "Expected revert for insufficient balance");
}
});
});
4. Testy Wydajnościowe
W przypadku dużych systemów blockchainowych testy wydajnościowe mogą ujawnić problemy związane z limitem gazu, które mogą być trudne do przewidzenia.
- Testy gazu: Zmierz zużycie gazu dla różnych funkcji, zwłaszcza tych operujących na dużych zbiorach danych lub skomplikowanych operacjach matematycznych.
- Symulacja dużego ruchu: Sprawdź, czy kontrakt działa poprawnie, gdy jest używany przez wielu użytkowników jednocześnie.
- Optymalizacja kosztów: Sprawdź, czy funkcje można zoptymalizować pod kątem gazu, aby były tańsze w użyciu.
Przykład testu gazu:
const MyContract = artifacts.require("MyContract");
contract("MyContract", accounts => {
it("should not exceed gas limit on function execution", async () => {
const instance = await MyContract.deployed();
const gasEstimate = await instance.someHeavyFunction.estimateGas();
assert.isBelow(gasEstimate, 3000000, "Function uses too much gas");
});
});
5. Testy Bezpieczeństwa
Bezpieczeństwo jest kluczowe, szczególnie w złożonych kontraktach, które zarządzają środkami finansowymi lub danymi wrażliwymi. Warto przeprowadzić testy, które wykryją potencjalne luki i sprawdzą mechanizmy ochronne.
- Testy kontroli dostępu: Sprawdź, czy tylko uprawnione adresy mogą wywoływać funkcje wymagające autoryzacji.
- Testy ochrony przed przepełnieniem liczb: W przypadku funkcji operujących na liczbach całkowitych upewnij się, że kontrakt korzysta z
SafeMathi nie jest podatny na błędy przepełnienia. - Testy odporności na ataki re-entrancy: Symuluj ataki typu re-entrancy, aby upewnić się, że funkcje są bezpieczne.
Przykład testu bezpieczeństwa:
const MyContract = artifacts.require("MyContract");
contract("MyContract", accounts => {
it("should prevent non-owners from executing restricted functions", async () => {
const instance = await MyContract.deployed();
try {
await instance.restrictedFunction({ from: accounts[1] });
assert.fail("Non-owner should not execute restricted function");
} catch (error) {
assert.include(error.message, "revert", "Expected revert for unauthorized access");
}
});
});
6. Testy End-to-End (E2E)
Testy E2E sprawdzają działanie całego systemu od początku do końca. Umożliwiają one sprawdzenie, jak aplikacja działa w rzeczywistych warunkach i czy spełnia założenia biznesowe.
- Testowanie pełnego cyklu: Przetestuj całą logikę kontraktu, np. od rejestracji użytkownika, przez zakup tokenów, aż po wypłatę środków.
- Interakcje między warstwami: Jeśli aplikacja składa się z kilku kontraktów lub interfejsu użytkownika (np. aplikacji dApp), upewnij się, że współpracują one poprawnie.
- Testowanie na różnych sieciach: Przetestuj kontrakt na różnych środowiskach, takich jak lokalna sieć Ganache, testnet oraz mainnet, aby upewnić się, że działa poprawnie w rzeczywistych warunkach.
Przykład testu E2E:
const Token = artifacts.require("Token");
const Crowdsale = artifacts.require("Crowdsale");
contract("Crowdsale End-to-End", accounts => {
it("should allow user to purchase and transfer tokens", async () => {
const token = await Token.deployed();
const crowdsale = await Crowdsale.deployed();
// Użytkownik kupuje tokeny
await crowdsale.buyTokens(accounts[1], { value: web3.utils.toWei("1", "ether") });
// Sprawdzenie salda tokenów
const balance = await token.balanceOf(accounts[1]);
assert.isAbove(balance.toNumber(), 0, "Tokens were not purchased correctly");
// Transfer tokenów
await token.transfer(accounts[2], balance, { from: accounts[1] });
const finalBalance = await token.balanceOf(accounts[2]);
assert.equal(finalBalance.toNumber(), balance.toNumber(), "Tokens were not transferred correctly");
});
});
Podsumowanie Zasad Testowania dla Złożonych Kontraktów
- Rozpocznij od testów jednostkowych, aby sprawdzić każdą funkcję osobno i izolować błędy.
- Dodaj testy integracyjne, aby upewnić się, że funkcje współpracują ze sobą poprawnie.
- Przeprowadź testy graniczne i negatywne, sprawdzając reakcję kontraktu na nietypowe i nieoczekiwane dane.
- Optymalizuj pod kątem gazu i sprawdź, czy funkcje nie zużywają więcej zasobów niż powinny.
- Skup się na bezpieczeństwie, przeprowadzając testy kontroli dostępu, testy ochrony przed przepełnieniem i testy odporności na ataki.
- Przeprowadź testy E2E, aby symulować pełne interakcje i upewnić się, że kontrakt działa poprawnie w rzeczywistych warunkach.
Dzięki przestrzeganiu tych zasad można znacząco zminimalizować ryzyko błędów i zapewnić, że kontrakt blockchainowy działa stabilnie, wydajnie i bezpiecznie.