Skip to content

Latest commit

 

History

History
494 lines (321 loc) · 18.7 KB

File metadata and controls

494 lines (321 loc) · 18.7 KB

Neo.SmartContract.Testing

The Neo.SmartContract.Testing project is designed to facilitate the development of unit tests for smart contract developers in neo, it does not require the project to be done in C#, as it is possible to export artifacts from an Abi.

Table of Contents

Installation and configuration

Generating Artifacts

The process of generating the artifacts, or the source code necessary to interact with the contract, is extremely simple. There are two main ways to do it:

  1. Using the ContractManifest of a contract, the necessary source code to interact with the contract can be generated by calling the GetArtifactsSource method available in the Neo.SmartContract.Testing.Extensions.ArtifactExtensions class, we will only have to specify the name of our resulting class, which will usually be the same as the one existing in the Name field of the manifest.

  2. Through the Neo C# compiler, automatically when compiling a contract in C#, the necessary source code to interact with the contract is generated. This is available in the same path as the generated .nef file, and its extension are .artifacts.cs and .artifacts.dll. Using the --generate-artifacts argument in Neo.Compiler.CSharp followed by the type of artifacts, with the options being: none, source, library, and all.

Example of use
[TestClass]
public class MyUnitTestClass
{
    [TestMethod]
    public void GenerateNativeArtifacts()
    {
        foreach (var n in Native.NativeContract.Contracts)
        {
            var manifest = n.Manifest;
            var source = manifest.GetArtifactsSource();

            File.WriteAllText($"{manifest.Name}.cs", source);
        }
    }
}

TestEngine

The TestEngine class is the main class of the library, providing a simple and intuitive interface for testing smart contracts.

Properties

The publicly exposed read-only properties are as follows:

  • ProtocolSettings: Assigned during the construction of the TestEngine and defines the configuration values of the test environment. It defaults to the current blockchain protocol.
  • Sender: Returns the script hash of the transaction sender, which corresponds to the first Signer defined in the Transaction object.
  • Native: Allows access to the native contracts, and their state. It facilitates access to the chain's native contracts through some precompiled artifacts. This point is further detailed in NativeArtifacts.
  • ValidatorsAddress: Defines the address for the validators of the defined ProtocolSettings.
  • CommitteeAddress: Returns the address of the current chain's committee.
  • Transaction: Defines the transaction that will be used as ScriptContainer for the neo virtual machine, by default it updates the script of the same as calls are composed and executed, and the Signers will be used as validators for the CheckWitness, regardless of whether the signature is correct or not, so if you want to test with different wallets or scopes, you do not need to sign the transaction correctly, just set the desired signers.
  • PersistingBlock: The block that will be persisted.
  • Storage: Abstracts access to storage, allowing for easy Snapshots as well as reverting them. Allows access to the storage of contracts, as well as manually altering their state. It's worth noting that a storage class is provided, which allows for reading the storage from an RPC endpoint. The class in question is named RpcStore and is available in the namespace Neo.SmartContract.Testing.Storage.Rpc.

And for read and write, we have:

  • Fee: Sets the fee execution limit for contract calls. Sets the NetworkFee of the Transaction object.
  • FeeConsumed: Get or set the consumed execution fee.
  • EnableCoverageCapture: Enables or disables the coverage capture.
  • Trigger: The trigger of the execution.
  • StringInterpreter: Define the interpreter used when a ByteString or Buffer is converted to string type.
  • CallFlags: Define the CallFlags for the mocked function, All by default.
  • OnGetEntryScriptHash: This feature makes it easy to change the EntryScriptHash.
  • OnGetCallingScriptHash: This feature makes it easy to change the CallingScriptHash.

Methods

It has four methods:

  • Execute(script): Executes a script on the neo virtual machine and returns the execution result.
  • Deploy(nef, manifest, data, customMock): Deploys the smart contract by calling the native method ContractManagement.deploy. It allows setting custom mocks, which will be detailed later. And returns the instance of the contract that has been deployed.
  • FromHash(hash, customMocks, checkExistence): Creates an instance without needing a NefFile or Manifest, only requiring the contract's hash. It does not consider whether the contract exists on the chain unless checkExistence is set to true.
  • SetTransactionSigners(signers): Set the Signer of the Transaction.
  • GetNewSigner(scope): A static method that provides us with a random Signer signed by default by CalledByEntry.
  • GetDeployHash(nef, manifest): Gets the hash that will result from deploying a contract with the defined NefFile and Manifest.

Example of use

// Create the engine initializing the native contracts

var engine = new TestEngine(true);

// Instantiate neo contract from native hash, (not necessary if we use engine.Native.NEO)

var neo = engine.FromHash<NeoToken>(engine.Native.NEO.Hash, false);

// Ensure that the main address contains the totalSupply

Assert.AreEqual(100_000_000, neo.TotalSupply);
Assert.AreEqual(neo.TotalSupply, neo.BalanceOf(engine.ValidatorsAddress));

NativeArtifacts

This class provides precompiled artifacts for neo's native contracts, thereby simplifying and facilitating calls to native contracts.

Methods

It has only one method:

  • Initialize(bool commit = false): Initializes the native contract with the necessary parameters for its operation. It's important to note that this step must usually be performed, or deploying contracts won't be possible. However, if using a Storage that already contains chain data and these contracts have been initialized, calling this method should be avoided. The commit argument determines whether to commit to the active Snapshot of the TestStorage (default is false).

Example of use

// Create the engine initializing the native contracts

var engine = new TestEngine(true);

// Ensure that the main address contains the totalSupply

Assert.AreEqual(100_000_000, engine.Native.NEO.TotalSupply);
Assert.AreEqual(engine.Native.NEO.TotalSupply, engine.Native.NEO.BalanceOf(engine.ValidatorsAddress));

SmartContractStorage

Avoids dealing with prefixes foreign to the internal behavior of the storage, focusing the developer solely on accessing the storage of the contract, just as it is managed by the smart contract itself, allowing reading, injecting, and deleting entries of the contract in question.

Methods

Mainly exposes the methods Import, Export, Contains, Get, Put, and Remove, all of them responsible for reading and manipulating the contract's information.

Example of use

// Defines the prefix used to store the registration price in neo

byte[] registerPricePrefix = new byte[] { 13 };

// Create engine and initialize native contracts

TestEngine engine = new(true);

// Check previous data

Assert.AreEqual(100000000000, engine.Native.NEO.RegisterPrice);

// Alter data

engine.Native.NEO.Storage.Put(registerPricePrefix, BigInteger.MinusOne);

// Check altered data

Assert.AreEqual(BigInteger.MinusOne, engine.Native.NEO.RegisterPrice);

Checkpoints

Storage checkpoints can be created, allowing for a return to specific moments in the execution. This can be achieved with checkpoints.

To create a checkpoint, simply call Checkpoint() from a EngineStorage class or from our TestEngine.

Methods

It has the following methods:

  • Restore(snapshot): This method can also be called from an EngineStorage or from our TestEngine class. It is used to restore the storage to a specified checkpoint.
  • ToArray(): Exports the checkpoint to a byte[].
  • Write(stream): Writes the checkpoint to a Stream.

Example of use

// Create a new test engine with native contracts already initialized

var engine = new TestEngine(true);

// Check that all it works

Assert.AreEqual(100_000_000, engine.Native.NEO.TotalSupply);

// Create checkpoint

var checkpoint = engine.Storage.Checkpoint();

// Create new storage, and restore the checkpoint on it

var storage = new EngineStorage(new MemoryStore());
checkpoint.Restore(storage.Snapshot);

// Create new test engine without initialize
// and set the storage to the restored one

engine = new TestEngine(false) { Storage = storage };

// Ensure that all works

Assert.AreEqual(100_000_000, engine.Native.NEO.TotalSupply);

Custom mocks

Custom mocks allow redirecting certain calls to smart contracts so that instead of calling the underlying contract, the logic is redirected to a method in .NET, allowing the developer to test in complex environments without significant issues.

Imagine that our project checks that our account has a balance of 123 NEO. It would be enough to redirect the calls to the NEO balanceOf method in the following way, so that it always returns 123.

It's important to note that all syscalls going to this contract will also be redirected, not only the calls to the method in .NET.

Example of use

// Initialize TestEngine and native smart contracts

TestEngine engine = new(true);

// Get neo token smart contract and mock balanceOf to always return 123

var neo = engine.FromHash<NeoToken>(engine.Native.NEO.Hash,
    mock => mock.Setup(o => o.BalanceOf(It.IsAny<UInt160>())).Returns(123),
    false);

// Test direct call

Assert.AreEqual(123, neo.BalanceOf(engine.ValidatorsAddress));

// Test vm call

using (ScriptBuilder script = new())
{
    script.EmitDynamicCall(neo.Hash, "balanceOf", engine.ValidatorsAddress);

    Assert.AreEqual(123, engine.Execute(script.ToArray()).GetInteger());
}

Fee watcher

It is possible to check the fee being used between multiple calls using the FeeWatcher class. To do this you can call the CreateFeeWatcher method of TestEngine or directly use the FeeConsumed property.

Example of use

using var fee = engine.CreateFeeWatcher();
{
    Assert.AreEqual("GAS", engine.Native.GAS.Symbol);
    Assert.AreEqual(984060L, fee);
}

Forging signatures

To fake signatures and allow testing our contracts in authorized and unauthorized environments, it's enough to replace the signers of the Transaction object in our TestEngine. This way, we can simulate the signatures of other users. It's worth noting that it's not necessary to modify the Witnesses since it's not checked whether the transaction is well-formed.

Example of use

// Initialize out TestEngine

var engine = new TestEngine(true);

// Check initial value of getRegisterPrice

Assert.AreEqual(100000000000, engine.Native.NEO.RegisterPrice);

// Fake Committee Signature

engine.SetTransactionSigners(new Network.P2P.Payloads.Signer()
{
    Account = engine.CommitteeAddress,
    Scopes = Network.P2P.Payloads.WitnessScope.Global
});

// Change RegisterPrice to 123

engine.Native.NEO.RegisterPrice = 123;

Assert.AreEqual(123, engine.Native.NEO.RegisterPrice);

// Now test it without this signature

engine.SetTransactionSigners(TestEngine.GetNewSigner());

Assert.ThrowsException<TargetInvocationException>(() => engine.Native.NEO.RegisterPrice = 123);

Event testing

Testing that our events have been triggered has never been so easy. Simply when a contract notification is launched, the corresponding event will be invoked, making it easier to capture and detect.

Example of use

// Create and initialize TestEngine

var engine = new TestEngine(true);

// Fake signature of ValidatorsAddress

engine.SetTransactionSigners(new Network.P2P.Payloads.Signer()
{
    Account = engine.ValidatorsAddress,
    Scopes = Network.P2P.Payloads.WitnessScope.Global
});

// Define address to transfer funds

UInt160 addressTo = UInt160.Parse("0x1230000000000000000000000000000000000000");

// Attach to Transfer event

var raisedEvent = false;

engine.Native.NEO.OnTransfer += (UInt160 from, UInt160 to, BigInteger amount) =>
{
    Assert.AreEqual(engine.Transaction.Sender, from);
    Assert.AreEqual(addressTo, to);
    Assert.AreEqual(123, amount);

    // If the event is raised, the variable will be changed
    raisedEvent = true;
};


Assert.AreEqual(0, engine.Native.NEO.BalanceOf(addressTo));

// Transfer funds

Assert.IsTrue(engine.Native.NEO.Transfer(engine.Transaction.Sender, addressTo, 123, null));

// Ensure that we have balance and the event was raised

Assert.IsTrue(raisedEvent);
Assert.AreEqual(123, engine.Native.NEO.BalanceOf(addressTo));

Coverage Calculation

To calculate the coverage of a contract, it is enough to call the GetCoverage method of our TestEngine, it require the EnableCoverageCapture property of the TestEngine to be enabled.

var engine = new TestEngine(true);

// Get NEO Coverage (NULL)

Assert.IsNull(engine.GetCoverage(engine.Native.NEO));

// Call NEO.TotalSupply

Assert.AreEqual(100_000_000, engine.Native.NEO.TotalSupply);

// Check that the 3 instructions has been covered

Assert.AreEqual(3, engine.GetCoverage(engine.Native.NEO)?.CoveredInstructions);
Assert.AreEqual(3, engine.GetCoverage(engine.Native.NEO)?.HitsInstructions);

It is also possible to call it to obtain the specific coverage of a method, either through an expression or manually.

var engine = new TestEngine(true);

// Call NEO.TotalSupply

Assert.AreEqual(100_000_000, engine.Native.NEO.TotalSupply);

// Oracle was not called

var methodCovered = engine.GetCoverage(engine.Native.Oracle, o => o.Finish());
Assert.IsNull(methodCovered);

// NEO.TotalSupply is covered

methodCovered = engine.GetCoverage(engine.Native.NEO, o => o.TotalSupply);
Assert.AreEqual(3, methodCovered?.TotalInstructions);
Assert.AreEqual(3, methodCovered?.CoveredInstructions);

// Check coverage by raw method

methodCovered = engine.GetCoverage(engine.Native.Oracle, "finish", 0);
Assert.IsNull(methodCovered);

methodCovered = engine.GetCoverage(engine.Native.NEO, "totalSupply", 0);
Assert.AreEqual(3, methodCovered?.TotalInstructions);
Assert.AreEqual(3, methodCovered?.CoveredInstructions);

Additionally, it's important to highlight that both method and contract coverages have a Dump method, through which one can obtain a text or HTML representation of the coverage. You might be interested in adding a unit test that checks the coverage at the end of execution, you can do it as shown below:

[TestClass]
public class CoverageContractTests
{
    /// <summary>
    /// Required coverage to be success
    /// </summary>
    public static float RequiredCoverage { get; set; } = 1F;

    [AssemblyCleanup]
    public static void EnsureCoverage()
    {
        // Join here all of your Coverage sources

        var coverage = Nep17ContractTests.Coverage;
        coverage?.Join(OwnerContractTests.Coverage);

        // Ennsure that the coverage is more than X% at the end of the tests

        Assert.IsNotNull(coverage);
        Console.WriteLine(coverage.Dump());

        File.WriteAllText("coverage.html", coverage.Dump(Testing.Coverage.DumpFormat.Html));
        Assert.IsTrue(coverage.CoveredPercentage >= RequiredCoverage, $"Coverage is less than {RequiredCoverage:P2}");
    }
}

Keep in mind that the coverage is at the instruction level, but you can also get the project's coverage on the source code using the debug file (*.nefdbgnfo) generated by Neo.Compiler.CSharp. To do this, you need to compile the project with the -d or --debug argument, and set up a unit test like the following:

[TestClass]
public class CoverageContractTests
{
    /// <summary>
    /// Required coverage to be success
    /// </summary>
    public static decimal RequiredCoverage { get; set; } = 1M;

    [AssemblyCleanup]
    public static void EnsureCoverage()
    {
        // Join here all of your coverage sources

        var coverage = Nep17ContractTests.Coverage;
        coverage?.Join(OwnerContractTests.Coverage);

        // Dump coverage to console

        Assert.IsNotNull(coverage, "Coverage can't be null");
        Console.WriteLine(coverage.Dump());

        // Write basic instruction html coverage

        File.WriteAllText("coverage.instruction.html", coverage.Dump(DumpFormat.Html));

        // Load our debug file

        if (NeoDebugInfo.TryLoad("templates/neocontractnep17/Artifacts/Nep17Contract.nefdbgnfo", out var dbg))
        {
            // Write the cobertura format

            File.WriteAllText("coverage.cobertura.xml", coverage.Dump(new CoberturaFormat((coverage, dbg))));

            // Write the report to the specific path

            CoverageReporting.CreateReport("coverage.cobertura.xml", "./coverageReport/");
        }

        // Ensure that the coverage is more than X% at the end of the tests

        Assert.IsTrue(coverage.CoveredLinesPercentage >= RequiredCoverage, $"Coverage is less than {RequiredCoverage:P2}");
    }
}

Known limitations

The currently known limitations are:

  • Receive events during the deploy, because the object is returned after performing the deploy, it is not possible to intercept notifications for the deploy unless the contract is previously created with FromHash knowing the hash of the contract to be created.
  • It is possible that if the contract is updated, the coverage calculation may be incorrect. The update method of a contract can be tested, but if the same script and abi as the original are not used, it can result in a coverage calculation error.