Skip to content

Commit

Permalink
Jest Matchers for Smart Contract Development (#321)
Browse files Browse the repository at this point in the history
* initial commit for jest matchers

* make the simplest test work: toBeProperAddress

* make tests for toBeProperAddress pass

* refactor matcher to its own folder

* rename properAddress to toBeProperAddress

* implement toBeProperPrivateKey

* implement toBeProperHex

* add BigNumber jest matchers

* implement toChangeBalance

* implement toChangeBalances

* rename tests to get CI to pass

* implement toBeReverted

* implement toBeRevertedWith

* tighten up types

* implement toEmit

* remove union test

* implement first rough implementation of toEmitWithArgs

* implement calledOnContract and validators

* implement calledOnContractWith

* rename jest.d.ts to types.d.ts

* update files dependent on types

* rewritten matchers for events emitted

* add jest timeout 10 sec

* ensure message always returns a string
  • Loading branch information
adrianmcli authored Sep 1, 2020
1 parent 6c093f4 commit a8ef989
Show file tree
Hide file tree
Showing 38 changed files with 4,014 additions and 27 deletions.
5 changes: 5 additions & 0 deletions waffle-jest/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const baseConfig = require("../.eslintrc.json");

module.exports = {
...baseConfig,
};
5 changes: 5 additions & 0 deletions waffle-jest/jest.config.js
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']
};
55 changes: 55 additions & 0 deletions waffle-jest/package.json
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"
}
}
38 changes: 38 additions & 0 deletions waffle-jest/src/index.ts
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
};
74 changes: 74 additions & 0 deletions waffle-jest/src/matchers/bigNumber.ts
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
? {
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}`
};
}
};
32 changes: 32 additions & 0 deletions waffle-jest/src/matchers/calledOnContract/calledOnContract.ts
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 {
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'
);
}
32 changes: 32 additions & 0 deletions waffle-jest/src/matchers/calledOnContract/calledOnContractWith.ts
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'
};
}
7 changes: 7 additions & 0 deletions waffle-jest/src/matchers/calledOnContract/error.ts
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'
);
}
}
13 changes: 13 additions & 0 deletions waffle-jest/src/matchers/calledOnContract/utils.ts
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);
}
}
12 changes: 12 additions & 0 deletions waffle-jest/src/matchers/toBeProperAddress.ts
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`
};
}
13 changes: 13 additions & 0 deletions waffle-jest/src/matchers/toBeProperHex.ts
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}`
};
}
12 changes: 12 additions & 0 deletions waffle-jest/src/matchers/toBeProperPrivateKey.ts
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`
};
}
30 changes: 30 additions & 0 deletions waffle-jest/src/matchers/toBeReverted.ts
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}`
};
}
}
}
Loading

0 comments on commit a8ef989

Please sign in to comment.