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
.
- Installation and configuration
- TestEngine
- NativeArtifacts
- SmartContractStorage
- Checkpoints
- Custom mocks
- Fee watcher
- Forging signatures
- Event testing
- Coverage Calculation
- Known limitations
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:
-
Using the
ContractManifest
of a contract, the necessary source code to interact with the contract can be generated by calling theGetArtifactsSource
method available in theNeo.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 theName
field of the manifest. -
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 inNeo.Compiler.CSharp
followed by the type of artifacts, with the options being:none
,source
,library
, andall
.
[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);
}
}
}
The TestEngine
class is the main class of the library, providing a simple and intuitive interface for testing smart contracts.
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 theTransaction
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 theSigners
will be used as validators for theCheckWitness
, 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 namedRpcStore
and is available in the namespaceNeo.SmartContract.Testing.Storage.Rpc
.
And for read and write, we have:
- Fee: Sets the fee execution limit for contract calls. Sets the
NetworkFee
of theTransaction
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.
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
orManifest
, only requiring the contract's hash. It does not consider whether the contract exists on the chain unlesscheckExistence
is set totrue
. - SetTransactionSigners(signers): Set the
Signer
of theTransaction
. - GetNewSigner(scope): A static method that provides us with a random
Signer
signed by default byCalledByEntry
. - GetDeployHash(nef, manifest): Gets the hash that will result from deploying a contract with the defined
NefFile
andManifest
.
// 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));
This class provides precompiled artifacts for neo's native contracts, thereby simplifying and facilitating calls to native contracts.
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. Thecommit
argument determines whether to commit to the activeSnapshot
of theTestStorage
(default isfalse
).
// 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));
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.
Mainly exposes the methods Import
, Export
, Contains
, Get
, Put
, and Remove
, all of them responsible for reading and manipulating the contract's information.
// 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);
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
.
It has the following methods:
- Restore(snapshot): This method can also be called from an
EngineStorage
or from ourTestEngine
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
.
// 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 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.
// 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());
}
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.
using var fee = engine.CreateFeeWatcher();
{
Assert.AreEqual("GAS", engine.Native.GAS.Symbol);
Assert.AreEqual(984060L, fee);
}
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.
// 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);
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.
// 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));
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}");
}
}
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.