Skip to content

Unit tests

In this page you write your first tests in Elci, run them with elci test and learn to read what it prints. Unit tests are the quickest way to check the pure functions your validators rely on, such as fee calculations or datum helpers.

Every test compiles to its own small program and runs on the same Plutus VM that evaluates your contracts. So a test tells you both whether your code is right and what it costs in CPU and memory.

Start from a project made by elci init shop. It already contains a validator, src/Main.elm, and two tests for it in tests/MainTest.elm. Add a module that computes a 0.3% trading fee:

src/Pricing.elm
module Pricing exposing (Quote, fee, quote)
type alias Quote =
{ amount : Int
, fee : Int
, total : Int
}
{-| A 0.3% fee (30 basis points), rounded down.
-}
fee : Int -> Int
fee amount =
amount * 30 // 10000
quote : Int -> Quote
quote amount =
{ amount = amount
, fee = fee amount
, total = amount + fee amount
}

// is integer division that truncates toward zero, so small amounts pay no fee at all. That is exactly the kind of detail worth pinning down with a test.

Tests live in modules under tests/. Create tests/PricingTest.elm:

tests/PricingTest.elm
module PricingTest exposing
( feeOnTenThousand
, feesRoundDown
, feesStayUnderOnePercent
, quotesAddTheFee
, smallAmountsAreFree
)
import Expect exposing (Expectation)
import Pricing
smallAmountsAreFree : Bool
smallAmountsAreFree =
Pricing.fee 300 == 0
feeOnTenThousand : Expectation
feeOnTenThousand =
Expect.equal 30 (Pricing.fee 10000)
quotesAddTheFee : Expectation
quotesAddTheFee =
Expect.equal { amount = 10000, fee = 30, total = 10030 } (Pricing.quote 10000)
feesRoundDown : Expectation
feesRoundDown =
Expect.every
[ Expect.equal 0 (Pricing.fee 333)
, Expect.equal 1 (Pricing.fee 334)
, Expect.equal 1 (Pricing.fee 666)
]
feesStayUnderOnePercent : Expectation
feesStayUnderOnePercent =
Expect.lessThan 10000 (Pricing.fee 1000000)
|> Expect.onFail "a fee must stay under 1% of the amount"

There are two kinds of unit test here:

  • A Bool test passes when it evaluates to True. It is the simplest possible check.
  • An Expectation test uses the Expect module. When it fails, it tells you what it expected and what it got, which a Bool cannot.

The project now looks like this:

  • Directoryshop/
    • elci.toml
    • Directorysrc/
      • Main.elm the validator from elci init
      • Pricing.elm
    • Directorytests/
      • MainTest.elm scenarios from elci init
      • PricingTest.elm

Run elci test from the project directory, or pass the directory as an argument:

Terminal window
elci test
PASS MainTest.ownerSigns (cpu 7675163, mem 29990)
PASS MainTest.othersAreRejected (cpu 8053596, mem 31922)
PASS PricingTest.smallAmountsAreFree (cpu 675727, mem 2604)
PASS PricingTest.feeOnTenThousand (cpu 929121, mem 4068)
PASS PricingTest.quotesAddTheFee (cpu 4498493, mem 12685)
PASS PricingTest.feesRoundDown (cpu 5947066, mem 24719)
PASS PricingTest.feesStayUnderOnePercent (cpu 1794288, mem 8097)
7 passed, 0 failed

Each line names the test as Module.name and shows the CPU steps and memory units its evaluation consumed. Modules run in alphabetical order, and the tests of a module in source order. The two MainTest tests are transaction scenarios: for those, the numbers are the cost of the validator itself.

elci test only reads your project. It never writes build/ and its tests never become part of elci check, elci build or the published scripts.

To see failures, break three tests on purpose. Check Pricing.fee 400 == 0 in the Bool test, expect 31 in feeOnTenThousand, and expect a total of 10031 in quotesAddTheFee:

FAIL PricingTest.smallAmountsAreFree (cpu 675727, mem 2604)
evaluated to False
FAIL PricingTest.feeOnTenThousand (cpu 1991208, mem 7866)
Expect.equal
expected: 31
actual: 30
FAIL PricingTest.quotesAddTheFee (cpu 5657982, mem 17219)
Expect.equal
expected: Constr 0 [10000, 30, 10031]
actual: Constr 0 [10000, 30, 10030]
PASS PricingTest.feesRoundDown (cpu 5947066, mem 24719)
PASS PricingTest.feesStayUnderOnePercent (cpu 1794288, mem 8097)
2 passed, 3 failed

The Bool test can only say that it evaluated to False. The expectations show both values.

Values in a failure are printed as their Plutus Data encoding, because that is the form the test program returns from the VM. The encoding follows the rules of your datums and redeemers:

Elci valuePrinted as
30, -530, -5
Bytes.fromString "GOLD" and the String "GOLD"#474f4c44 (hex of the UTF-8 bytes)
False, TrueConstr 0, Constr 1
Nothing, Just 5Constr 0, Constr 1 [5]
[ 1, 2 ][1, 2]
( 1, True )Constr 0 [1, Constr 1]
a recordConstr 0 [...], with the fields in alphabetical order of their names
a custom typeConstr tag [...], where the tag is the constructor’s position, from 0

So Constr 0 [10000, 30, 10031] is the Quote record with fields amount, fee and total.

A test can also fail at runtime, for example by dividing by zero. It is then reported with the VM’s error, such as evaluation failed: QuotientInteger: division by zero. Debugging and budgets covers these messages and how to add traces.

Expect has a small set of functions in the style of elm-test. Import it with import Expect exposing (Expectation).

FunctionPasses when
Expect.equal expected actualthe two values are structurally equal
Expect.notEqual unexpected actualthe two values differ
Expect.atMost bound actualactual <= bound
Expect.atLeast bound actualactual >= bound
Expect.lessThan bound actualactual < bound
Expect.greaterThan bound actualactual > bound
Expect.passalways
Expect.fail messagenever; the failure shows message
Expect.every [ e1, e2 ]every expectation passes; the first failure is reported
Expect.onFail message expectationexpectation passes; otherwise its message is replaced

The comparisons work on any comparable values, such as integers, strings and bytes. They take the bound first and the actual value last, so they read well in a pipeline. When one fails, the report lists the bound and then the actual value:

feesAreCapped : Expectation
feesAreCapped =
Expect.atMost 100 (Pricing.fee 50000)
FAIL PricingTest.feesAreCapped (cpu 2126712, mem 8766)
Expect.atMost
values: 100, 150

Expect.onFail puts your own explanation in place of the function name:

feesAreCappedExplained : Expectation
feesAreCappedExplained =
Expect.atMost 100 (Pricing.fee 50000)
|> Expect.onFail "fees are capped at 100 lovelace"
FAIL PricingTest.feesAreCappedExplained (cpu 5046289, mem 18138)
fees are capped at 100 lovelace
values: 100, 150

Expect.every combines several checks into one test and reports the first that fails. Its argument is a list of evaluated expectations: Elci lists hold data, not functions, so there is no elm-test style Expect.all that takes checks as functions.

elci test treats a declaration in a test module as a test when it is:

  1. exposed by its module,
  2. a top-level declaration with no arguments, and
  3. of type Bool, Expect.Expectation, Scenario.Scenario or Property.Property.

An exposed function whose result is a Property is also a test: a property-based test that runs many times with generated arguments.

Everything else is a helper. That includes functions that return an Expectation, a Scenario or a Bool, such as a function that builds a transaction for a given signer. With an explicit exposing list you choose exactly which declarations are tests. With exposing (..), every zero-argument declaration of a test type runs, and declarations of other types, such as a test key of type PubKeyHash, are skipped.

--match runs only the tests whose full name contains a substring. The match ignores case and includes the module name:

Terminal window
elci test --match fee
PASS PricingTest.feeOnTenThousand (cpu 929121, mem 4068)
PASS PricingTest.quotesAddTheFee (cpu 4498493, mem 12685)
PASS PricingTest.feesRoundDown (cpu 5947066, mem 24719)
PASS PricingTest.feesStayUnderOnePercent (cpu 1794288, mem 8097)
4 passed, 0 failed

quotesAddTheFee matches because its name contains Fee. Use --match PricingTest to run one module.

By default, test modules are the .elm files under tests/. To use other directories, list them in elci.toml:

elci.toml
[tests]
source-directories = ["tests", "spec"]

A few rules apply to test modules:

  • Imports. A test module can import your project’s modules, the Java modules configured in [java-modules], the test library (Expect, Scenario and Property) and other test modules. A shared module such as tests/Fixtures.elm is a good home for test keys and sample datums. Source modules cannot import the test library: elci test rejects such an import with ELCI2101.
  • Names. A test module cannot have the same name as a source module, or as Expect, Scenario or Property. elci test stops with ELCI4001: module Pricing is both a source and a test module if you try.
  • Isolation. Test modules are compiled only by elci test. They cannot change what elci build produces.

elci test exits with status 1 when any test fails, and also when no test is found or none matches --match:

No tests match 'nothing-here'

Otherwise it exits with 0. You can run it as a step in any CI pipeline without extra configuration.