Skip to content
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

Merged
merged 26 commits into from
Sep 1, 2020
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
79a6f7c
initial commit for jest matchers
adrianmcli Jun 19, 2020
2f2d18e
make the simplest test work: toBeProperAddress
adrianmcli Jun 19, 2020
04b68c5
make tests for toBeProperAddress pass
adrianmcli Jun 19, 2020
d316888
refactor matcher to its own folder
adrianmcli Jun 19, 2020
9a607a1
rename properAddress to toBeProperAddress
adrianmcli Jun 19, 2020
55c6084
implement toBeProperPrivateKey
adrianmcli Jun 19, 2020
89e9cfa
implement toBeProperHex
adrianmcli Jun 19, 2020
6b74d85
add BigNumber jest matchers
adrianmcli Jun 19, 2020
005150b
implement toChangeBalance
adrianmcli Jun 19, 2020
da665e0
implement toChangeBalances
adrianmcli Jun 19, 2020
e89f65d
rename tests to get CI to pass
adrianmcli Jun 19, 2020
63a350b
implement toBeReverted
adrianmcli Jun 19, 2020
48c6705
implement toBeRevertedWith
adrianmcli Jun 19, 2020
2ddd1c1
tighten up types
adrianmcli Jun 19, 2020
8dcd84d
implement toEmit
adrianmcli Jun 19, 2020
e5a6194
remove union test
adrianmcli Jun 19, 2020
36e90f1
implement first rough implementation of toEmitWithArgs
adrianmcli Jun 19, 2020
c14aae7
implement calledOnContract and validators
adrianmcli Jun 20, 2020
211b4c5
implement calledOnContractWith
adrianmcli Jun 20, 2020
402e657
rename jest.d.ts to types.d.ts
adrianmcli Jun 20, 2020
153412c
update files dependent on types
adrianmcli Jun 20, 2020
5ee1b20
rewritten matchers for events emitted
adrianmcli Jun 20, 2020
a316ea9
add jest timeout 10 sec
adrianmcli Jun 20, 2020
b4ca645
ensure message always returns a string
adrianmcli Jun 20, 2020
9264ec1
fix spacing and use absolute import
adrianmcli Aug 31, 2020
87ce88f
fix linting error
adrianmcli Sep 1, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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",
Copy link
Contributor

@marekkirejczyk marekkirejczyk Sep 1, 2020

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 in validateMockProvider could be relaxed.

"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
Copy link
Contributor

Choose a reason for hiding this comment

The 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}`
};

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Contributor

Choose a reason for hiding this comment

The 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}`
};
}
};
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 {
Copy link
Contributor

Choose a reason for hiding this comment

The 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. buidler.dev provider)

Copy link
Contributor

Choose a reason for hiding this comment

The 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

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where would I put this?

Copy link
Contributor

Choose a reason for hiding this comment

The 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.
If it becomes problematic, just revert the commit and we can fix in in next version.

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