-
Notifications
You must be signed in to change notification settings - Fork 162
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Jest Matchers for Smart Contract Development #321
Changes from 25 commits
79a6f7c
2f2d18e
04b68c5
d316888
9a607a1
55c6084
89e9cfa
6b74d85
005150b
da665e0
e89f65d
63a350b
48c6705
2ddd1c1
8dcd84d
e5a6194
36e90f1
c14aae7
211b4c5
402e657
153412c
5ee1b20
a316ea9
b4ca645
9264ec1
87ce88f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
const baseConfig = require("../.eslintrc.json"); | ||
|
||
module.exports = { | ||
...baseConfig, | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
module.exports = { | ||
preset: 'ts-jest', | ||
testEnvironment: 'node', | ||
setupFilesAfterEnv: ['./test/setupJest.ts'] | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
{ | ||
"name": "@ethereum-waffle/jest", | ||
"description": "A sweet set of jest matchers for your blockchain testing needs.", | ||
"version": "3.0.0", | ||
"author": "Adrian Li <li.adrianmc@gmail.com>", | ||
"repository": "git@github.com:EthWorks/Waffle.git", | ||
"private": false, | ||
"license": "MIT", | ||
"keywords": [ | ||
"ethereum", | ||
"smart-contracts", | ||
"solidity", | ||
"testing", | ||
"javascript", | ||
"typescript", | ||
"jest", | ||
"library" | ||
], | ||
"homepage": "https://github.com/EthWorks/Waffle", | ||
"bugs": { | ||
"url": "https://github.com/EthWorks/Waffle/issues" | ||
}, | ||
"main": "dist/cjs/index.js", | ||
"module": "dist/esm/index.ts", | ||
"types": "dist/esm/index.d.ts", | ||
"scripts": { | ||
"prepublishOnly": "yarn build", | ||
"test": "jest", | ||
"lint": "eslint '{src,test}/**/*.ts'", | ||
"lint:fix": "eslint --fix '{src,test}/**/*.ts'", | ||
"build": "rimraf ./dist && yarn build:esm && yarn build:cjs", | ||
"build:esm": "tsc -p tsconfig.build.json --outDir dist/esm --module ES6", | ||
"build:cjs": "tsc -p tsconfig.build.json --outDir dist/cjs --declaration false" | ||
}, | ||
"engines": { | ||
"node": ">=10.0" | ||
}, | ||
"dependencies": { | ||
"@ethereum-waffle/provider": "^3.0.0", | ||
"ethers": "^5.0.0", | ||
"jest-diff": "^26.0.1" | ||
}, | ||
"devDependencies": { | ||
"@types/jest": "^26.0.0", | ||
"@typescript-eslint/eslint-plugin": "^2.30.0", | ||
"@typescript-eslint/parser": "^2.30.0", | ||
"eslint": "^6.8.0", | ||
"eslint-plugin-import": "^2.20.2", | ||
"jest": "^26.0.1", | ||
"rimraf": "^3.0.0", | ||
"ts-jest": "^26.1.0", | ||
"ts-node": "^8.9.1", | ||
"typescript": "^3.8.3" | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
import {toBeProperAddress} from './matchers/toBeProperAddress'; | ||
import {toBeProperPrivateKey} from './matchers/toBeProperPrivateKey'; | ||
import {toBeProperHex} from './matchers/toBeProperHex'; | ||
import {bigNumberMatchers} from './matchers/bigNumber'; | ||
import {toChangeBalance} from './matchers/toChangeBalance'; | ||
import {toChangeBalances} from './matchers/toChangeBalances'; | ||
import {toBeReverted} from './matchers/toBeReverted'; | ||
import {toBeRevertedWith} from './matchers/toBeRevertedWith'; | ||
import {toHaveEmitted} from './matchers/toHaveEmitted/toHaveEmitted'; | ||
import {toHaveEmittedWith} from './matchers/toHaveEmitted/toHaveEmittedWith'; | ||
import {toBeCalledOnContract} from './matchers/calledOnContract/calledOnContract'; | ||
import {toBeCalledOnContractWith} from './matchers/calledOnContract/calledOnContractWith'; | ||
|
||
export const waffleJest = { | ||
// misc matchers | ||
toBeProperAddress, | ||
toBeProperPrivateKey, | ||
toBeProperHex, | ||
|
||
// BigNumber matchers | ||
...bigNumberMatchers, | ||
|
||
// balance matchers | ||
toChangeBalance, | ||
toChangeBalances, | ||
|
||
// revert matchers | ||
toBeReverted, | ||
toBeRevertedWith, | ||
|
||
// emit matchers | ||
toHaveEmitted, | ||
toHaveEmittedWith, | ||
|
||
// calledOnContract matchers | ||
toBeCalledOnContract, | ||
toBeCalledOnContractWith | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
import {BigNumber} from 'ethers'; | ||
import {Numberish} from '../types'; | ||
|
||
// NOTE: Jest does not currently support overriding matchers while calling | ||
// original implementation, therefore we have to name our matchers something | ||
// different: https://github.com/facebook/jest/issues/6243 | ||
|
||
export const bigNumberMatchers = { | ||
toEqBN(received: Numberish, value: Numberish) { | ||
const pass = BigNumber.from(received).eq(value); | ||
return pass | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This pattern can be simplified and condensed into: return {
pass,
message: () => pass
? `Expected "${received}" NOT to be greater than ${value}`
: `Expected "${received}" to be greater than ${value}`
}; There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree that's more concise, but I'd argue that it's a lot less readable because you are mixing a conditional into an object where one property should correspond to the other. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. More than fine for experimental merge |
||
? { | ||
pass: true, | ||
message: () => `Expected "${received}" NOT to be equal ${value}` | ||
} | ||
: { | ||
pass: false, | ||
message: () => `Expected "${received}" to be equal ${value}` | ||
}; | ||
}, | ||
toBeGtBN(received: Numberish, value: Numberish) { | ||
const pass = BigNumber.from(received).gt(value); | ||
return pass | ||
? { | ||
pass: true, | ||
message: () => | ||
`Expected "${received}" NOT to be greater than ${value}` | ||
} | ||
: { | ||
pass: false, | ||
message: () => `Expected "${received}" to be greater than ${value}` | ||
}; | ||
}, | ||
toBeLtBN(received: Numberish, value: Numberish) { | ||
const pass = BigNumber.from(received).lt(value); | ||
return pass | ||
? { | ||
pass: true, | ||
message: () => `Expected "${received}" NOT to be less than ${value}` | ||
} | ||
: { | ||
pass: false, | ||
message: () => `Expected "${received}" to be less than ${value}` | ||
}; | ||
}, | ||
toBeGteBN(received: Numberish, value: Numberish) { | ||
const pass = BigNumber.from(received).gte(value); | ||
return pass | ||
? { | ||
pass: true, | ||
message: () => | ||
`Expected "${received}" NOT to be greater than or equal ${value}` | ||
} | ||
: { | ||
pass: false, | ||
message: () => | ||
`Expected "${received}" to be greater than or equal ${value}` | ||
}; | ||
}, | ||
toBeLteBN(received: Numberish, value: Numberish) { | ||
const pass = BigNumber.from(received).lte(value); | ||
return pass | ||
? { | ||
pass: true, | ||
message: () => | ||
`Expected "${received}" NOT to be less than or equal ${value}` | ||
} | ||
: { | ||
pass: false, | ||
message: () => | ||
`Expected "${received}" to be less than or equal ${value}` | ||
}; | ||
} | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import {Contract} from 'ethers'; | ||
import { | ||
validateContract, | ||
validateMockProvider, | ||
validateFnName | ||
} from './calledOnContractValidators'; | ||
|
||
export function toBeCalledOnContract(fnName: string, contract: Contract) { | ||
validateContract(contract); | ||
validateMockProvider(contract.provider); | ||
if (fnName !== undefined) { | ||
validateFnName(fnName, contract); | ||
} | ||
|
||
const fnSighash = contract.interface.getSighash(fnName); | ||
const {callHistory} = contract.provider; | ||
|
||
const pass = callHistory.some( | ||
(call) => | ||
call.address === contract.address && call.data.startsWith(fnSighash) | ||
); | ||
|
||
return pass | ||
? { | ||
pass: true, | ||
message: () => 'Expected contract function NOT to be called' | ||
} | ||
: { | ||
pass: false, | ||
message: () => 'Expected contract function to be called' | ||
}; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
import {Contract} from 'ethers'; | ||
import {MockProvider} from '@ethereum-waffle/provider'; | ||
import {ProviderWithHistoryExpected} from './error'; | ||
import {ensure} from './utils'; | ||
|
||
export function validateContract(contract: any): asserts contract is Contract { | ||
ensure( | ||
contract instanceof Contract, | ||
TypeError, | ||
'argument must be a contract' | ||
); | ||
} | ||
|
||
export function validateMockProvider( | ||
provider: any | ||
): asserts provider is MockProvider { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We could remove that assertion, lib could work with any provider that has callHistory (e.g. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ahh, now it broke the build I Guess we could define type interface ProviderWithHistory {
callHistory ...
} and keep the assertion with it instead of MockProvider There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. where would I put this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In the same file I guess, it is just for validation purposes to let complier know about the type. |
||
ensure( | ||
!!provider.callHistory && provider.callHistory instanceof Array, | ||
ProviderWithHistoryExpected | ||
); | ||
} | ||
|
||
export function validateFnName( | ||
fnName: any, | ||
contract: Contract | ||
): asserts fnName is string { | ||
ensure( | ||
typeof fnName === 'string', | ||
TypeError, | ||
'function name must be a string' | ||
); | ||
function isFunction(name: string) { | ||
try { | ||
return !!contract.interface.getFunction(name); | ||
} catch (e) { | ||
return false; | ||
} | ||
} | ||
ensure( | ||
isFunction(fnName), | ||
TypeError, | ||
'function must exist in provided contract' | ||
); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import {Contract} from 'ethers'; | ||
import { | ||
validateContract, | ||
validateMockProvider, | ||
validateFnName | ||
} from './calledOnContractValidators'; | ||
|
||
export function toBeCalledOnContractWith( | ||
fnName: string, | ||
contract: Contract, | ||
parameters: any[] | ||
) { | ||
validateContract(contract); | ||
validateMockProvider(contract.provider); | ||
validateFnName(fnName, contract); | ||
|
||
const funCallData = contract.interface.encodeFunctionData(fnName, parameters); | ||
const {callHistory} = contract.provider; | ||
const pass = callHistory.some( | ||
(call) => call.address === contract.address && call.data === funCallData | ||
); | ||
|
||
return pass | ||
? { | ||
pass: true, | ||
message: () => 'Expected contract function with parameters NOT to be called' | ||
} | ||
: { | ||
pass: false, | ||
message: () => 'Expected contract function with parameters to be called' | ||
}; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
export class ProviderWithHistoryExpected extends Error { | ||
constructor() { | ||
super( | ||
'calledOnContract matcher requires provider that support call history' | ||
); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
interface ErrorConstructor<T extends any[]> { | ||
new (...args: T): Error; | ||
} | ||
|
||
export function ensure<T extends any[]>( | ||
condition: boolean, | ||
ErrorToThrow: ErrorConstructor<T>, | ||
...errorArgs: T | ||
): asserts condition { | ||
if (!condition) { | ||
throw new ErrorToThrow(...errorArgs); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
export function toBeProperAddress(received: string) { | ||
const pass = /^0x[0-9-a-fA-F]{40}$/.test(received); | ||
return pass | ||
? { | ||
pass: true, | ||
message: () => `Expected "${received}" not to be a proper address` | ||
} | ||
: { | ||
pass: false, | ||
message: () => `Expected "${received}" to be a proper address` | ||
}; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
export function toBeProperHex(received: string, length: number) { | ||
const regexp = new RegExp(`^0x[0-9-a-fA-F]{${length}}$`); | ||
const pass = regexp.test(received); | ||
return pass | ||
? { | ||
pass: true, | ||
message: () => `Expected "${received}" not to be a proper hex of length ${length}, but it was` | ||
} | ||
: { | ||
pass: false, | ||
message: () => `Expected "${received}" to be a proper hex of length ${length}` | ||
}; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
export function toBeProperPrivateKey(received: string) { | ||
const pass = /^0x[0-9-a-fA-F]{64}$/.test(received); | ||
return pass | ||
? { | ||
pass: true, | ||
message: () => `Expected "${received}" not to be a proper private key` | ||
} | ||
: { | ||
pass: false, | ||
message: () => `Expected "${received}" to be a proper private key` | ||
}; | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
export async function toBeReverted(promise: Promise<any>) { | ||
try { | ||
await promise; | ||
return { | ||
pass: false, | ||
message: () => 'Expected transaction to be reverted' | ||
}; | ||
} catch (error) { | ||
const message = | ||
error instanceof Object && 'message' in error ? error.message : JSON.stringify(error); | ||
|
||
const isReverted = message.search('revert') >= 0; | ||
const isThrown = message.search('invalid opcode') >= 0; | ||
const isError = message.search('code=') >= 0; | ||
|
||
const pass = isReverted || isThrown || isError; | ||
if (pass) { | ||
return { | ||
pass: true, | ||
message: () => 'Expected transaction NOT to be reverted' | ||
}; | ||
} else { | ||
return { | ||
pass: false, | ||
message: () => | ||
`Expected transaction to be reverted, but other exception was thrown: ${error}` | ||
}; | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would be good to move that dependency to
devDependencies
, to do so validation invalidateMockProvider
could be relaxed.