Skip to content

Commit

Permalink
Add DS-Test framework (#628)
Browse files Browse the repository at this point in the history
  • Loading branch information
richardpringle authored Apr 27, 2023
1 parent 94c973d commit f7b9283
Show file tree
Hide file tree
Showing 4 changed files with 188 additions and 0 deletions.
1 change: 1 addition & 0 deletions contract-examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"balances": "npx hardhat balances"
},
"dependencies": {
"ds-test": "https://github.com/dapphub/ds-test.git",
"typescript": "^4.3.5"
},
"engines": {
Expand Down
60 changes: 60 additions & 0 deletions contract-examples/test/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Testing Precompiles

If you can, put all of your test logic into DS-test tests. Prefix test functions with `test_`. There's also a `setUp` function that gets called before the test contract is deployed. The current best-practice is to re-deploy one test contract per `test` function called in `*.ts` test definitions. The `setUp` method should be called once, then `test_` functions passed in as the 2nd argument to `test("<description>", <test_function_name_OR_array_of_test_function_names>)` will be called in order. `test.only` and `test.skip` behave the same way as `it.only` and `it.skip`. There's also a `test.debug` that combines `test.only` with some extra event logging (you can use `emit log_string` to help debug Solidity test code).

The `test` function is a wrapper around Mocha's `it` function. It provides a normalized framework for running the
majority of your test assertions inside of a smart-contract, using `DS-Test`.
The API can be used as follows:

```js
test("<test_name>", "<contract_function_name>");
test("<test_name>", ["<contract_function_name>"]);
test("<test_name>", {
method: "<contract_function_name>",
overrides: {},
shouldFail: false,
debug: false,
});
test("<test_name>", [
{
method: "<contract_function_name>",
overrides: {},
shouldFail: false,
debug: false,
},
]);
test(
"<test_name>",
[{ method: "<contract_function_name>", shouldFail: false, debug: false }],
{}
);
```

All of the above examples are equivalent.
Many contract functions can be called as a part of the same test:

```js
test("<test_name>", [<test_fn1>, <test_fn2>, <test_fn3>])
```

Individual test functions can describe their own overrides with the `overrides` property.
If an object is passed in as the third argument to `test`, it will be used as the default overrides for all test
functions.
The following are equivalent:

```js
test("<test_name>", [
{ method: "<contract_function_name>", overrides: { from: "0x123" } },
]);
test("<test_name>", [{ method: "<contract_function_name>" }], {
from: "0x123",
});
```

In the above cases, the `from` override must be a signer.
The `shouldFail` property can be used to indicate that the test function should fail. This should be used sparingly
as it is not possible to match on the failure reason.
Furthermore, the `debug` property can be used to print any thrown errors when attempting to
send a transaction or while waiting for the transaction to be confirmed (the transaction is the smart contract call).
`debug` will also cause any parseable event logs to be printed that start with the `log_` prefix.
`DSTest` contracts have several options for emitting `log_` events.
123 changes: 123 additions & 0 deletions contract-examples/test/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { ethers } from "hardhat"
const assert = require("assert")

/*
*
* The `test` function is a wrapper around Mocha's `it` function. It provides a normalized framework for running the
* majority of your test assertions inside of a smart-contract, using `DS-Test`.
* The API can be used as follows:
* ```js
* test("<test_name>", "<contract_function_name>")
* test("<test_name>", ["<contract_function_name>"])
* test("<test_name>", { method: "<contract_function_name>", overrides: {}, shouldFail: false, debug: false })
* test("<test_name>", [{ method: "<contract_function_name>", overrides: {}, shouldFail: false, debug: false }])
* test("<test_name>", [{ method: "<contract_function_name>", shouldFail: false, debug: false }], {})
* ```
* All of the above examples are equivalent.
* Many contract functions can be called as a part of the same test:
* ```js
* test("<test_name>", [<test_fn1>, <test_fn2>, <test_fn3>])
* ```
* Individual test functions can describe their own overrides with the `overrides` property.
* If an object is passed in as the third argument to `test`, it will be used as the default overrides for all test
* functions.
* The following are equivalent:
* ```js
* test("<test_name>", [{ method: "<contract_function_name>", overrides: { from: "0x123" } }])
* test("<test_name>", [{ method: "<contract_function_name>" }], { from: "0x123" })
* ```
* In the above cases, the `from` override must be a signer.
* The `shouldFail` property can be used to indicate that the test function should fail. This should be used sparingly
* as it is not possible to match on the failure reason.
* Furthermore, the `debug` property can be used to print any thrown errors when attempting to
* send a transaction or while waiting for the transaction to be confirmed (the transaction is the smart contract call).
* `debug` will also cause any parseable event logs to be printed that start with the `log_` prefix.
* `DSTest` contracts have several options for emitting `log_` events.
*
*/

// Below are the types that help define all the different ways to call `test`
type FnNameOrObject = string | string[] | MethodObject | MethodObject[]
type MethodObject = { method: string, debug?: boolean, overrides?: any, shouldFail?: boolean }

// This type is after all default values have been applied
type MethodWithDebugAndOverrides = MethodObject & { debug: boolean, overrides: any, shouldFail: boolean }

// `test` is used very similarly to `it` from Mocha
export const test = (name, fnNameOrObject, overrides = {}) => it(name, testFn(fnNameOrObject, overrides))
// `test.only` is used very similarly to `it.only` from Mocha, it will isolate all tests marked with `test.only`
test.only = (name, fnNameOrObject, overrides = {}) => it.only(name, testFn(fnNameOrObject, overrides))
// `test.debug` is used to apply `debug: true` to all DSTest contract method calls in the test
test.debug = (name, fnNameOrObject, overrides = {}) => it.only(name, testFn(fnNameOrObject, overrides, true))
// `test.skip` is used very similarly to `it.skip` from Mocha, it will skip all tests marked with `test.skip`
test.skip = (name, fnNameOrObject, overrides = {}) => it.skip(name, testFn(fnNameOrObject, overrides))

// `testFn` is a higher-order function. It returns a function that can be used as the test function for `it`
const testFn = (fnNameOrObject: FnNameOrObject, overrides = {}, debug = false) => {
// normalize the input to an array of objects
const fnObjects: MethodWithDebugAndOverrides[] = (Array.isArray(fnNameOrObject) ? fnNameOrObject : [fnNameOrObject]).map(fnNameOrObject => {
fnNameOrObject = typeof fnNameOrObject === 'string' ? { method: fnNameOrObject } : fnNameOrObject
// assign all default values and overrides
fnNameOrObject.overrides = Object.assign({}, overrides, fnNameOrObject.overrides ?? {})
fnNameOrObject.debug = fnNameOrObject.debug ?? debug
fnNameOrObject.shouldFail = fnNameOrObject.shouldFail ?? false

return fnNameOrObject as MethodWithDebugAndOverrides
})

// only `test_` prefixed functions can be called on the `DSTest` contracts to clearly separate tests and helpers
assert(fnObjects.every(({ method }) => method.startsWith('test_')), "Solidity test functions must be prefixed with 'test_'")

// return the test function that will be used by `it`
// this function must be defined with the `function` keyword so that `this` is bound to the Mocha context
return async function() {
// `Array.prototype.reduce` is used here to ensure that the test functions are called in order.
// Each test function waits for its predecessor to complete before starting
return fnObjects.reduce((p: Promise<undefined>, fn) => p.then(async () => {
const contract = fn.overrides.from
? this.testContract.connect(await ethers.getSigner(fn.overrides.from))
: this.testContract
const tx = await contract[fn.method](fn.overrides).catch(err => {
if (fn.shouldFail) {
if (fn.debug) console.error(`smart contract call failed with error:\n${err}\n`)

return { failed: true }
}

console.error("smart contract call failed with error:", err)
throw err
})

// no more assertions necessary if the method-call should fail and did fail
if (tx.failed && fn.shouldFail) return

const txReceipt = await tx.wait().catch(err => {
if (fn.debug) console.error(`tx failed with error:\n${err}\n`)
return err.receipt
})

// `txReceipt.status` will be `0` if the transaction failed.
// `contract.callStatic.failed()` will return `true` if any of the `DSTest` assertions failed.
const failed = txReceipt.status === 0 ? true : await contract.callStatic.failed()
if (fn.debug || failed) {
console.log('')

if (!txReceipt.events) console.warn('WARNING: No parseable events found in tx-receipt\n')

// If `DSTest` assertions failed, the contract will emit logs describing the assertion failure(s).
txReceipt
.events
?.filter(event => fn.debug || event.event?.startsWith('log'))
.map(event => event.args?.forEach(arg => console.log(arg)))

console.log('')
}

if (fn.shouldFail) {
assert(failed, `${fn.method} should have failed`)
} else {
assert(!failed, `${fn.method} failed`)
}
}), Promise.resolve())
}
}
4 changes: 4 additions & 0 deletions contract-examples/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2688,6 +2688,10 @@ dotignore@~0.1.2:
dependencies:
minimatch "^3.0.4"

"ds-test@https://github.com/dapphub/ds-test.git":
version "1.0.0"
resolved "https://github.com/dapphub/ds-test.git#e282159d5170298eb2455a6c05280ab5a73a4ef0"

duplexer3@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
Expand Down

0 comments on commit f7b9283

Please sign in to comment.