diff --git a/README.md b/README.md index 0960006ee..5bc63ea1e 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,22 @@ expect('0x706618637b8ca922f6290ce1ecd4c31247e9ab75cf0530a0ac95c0332173d7c5').to. expect('0x70').to.be.properHex(2); ``` +* Testing whether the transaction changes balance +```js +await expect(() => myContract.transferWei(receiverWallet.address, 2)).to.changeBalance(receiverWallet, 2); +``` +_Note:_ transaction call should be passed to the _expect_ as a callback (we need to check the balance before the call). +The matcher can accept numbers, strings and BigNumbers as a balance change, while the address should be cpecified as a wallet. + +_changeBalance_ calls should not be chained. If you need to chain it, you probably want to use _changeBalances_ matcher. + +* Testing whether the transaction changes balance for multiple accounts +```js +await expect(() => myContract.transferWei(receiverWallet.address, 2)).to.changeBalances([senderWallet, receiverWallet], [-2, 2]); +``` +_Note:_ transaction call should be passed to the expect as a callback (we need to check the balance before the call) + + ## Roadmap * New matcher: changeBalance (see [#9](https://github.com/EthWorks/Waffle/issues/9)) diff --git a/lib/matchers.js b/lib/matchers.js index 1d9a79dd5..3e9d1f036 100644 --- a/lib/matchers.js +++ b/lib/matchers.js @@ -1,4 +1,6 @@ import overwriteBigNumberFunction from './matchers/overwriteBigNumberFunction'; +import {bigNumberify} from 'ethers/utils'; +import {getBalanceChange, getBalanceChanges} from './utils'; const solidity = (chai, utils) => { const {Assertion} = chai; @@ -140,6 +142,46 @@ const solidity = (chai, utils) => { 'proper address (eg.: 0x1234567890123456789012345678901234567890)', subject); }); + + Assertion.addMethod('changeBalance', function (wallet, balanceChange) { + const subject = this._obj; + if (typeof subject !== 'function') { + throw new Error(`Expect subject should be a callback returning the Promise + e.g.: await expect(() => wallet.send({to: '0xb', value: 200})).to.changeBalance('0xa', -200)`); + } + const derivedPromise = getBalanceChange(subject, wallet) + .then((actualChange) => { + this.assert(actualChange.eq(bigNumberify(balanceChange)), + `Expected "${wallet.address}" to change balance by ${balanceChange} wei, but it has changed by ${actualChange} wei`, + `Expected "${wallet.address}" to not change balance by ${balanceChange} wei,`, + balanceChange, + actualChange); + }); + this.then = derivedPromise.then.bind(derivedPromise); + this.catch = derivedPromise.catch.bind(derivedPromise); + this.promise = derivedPromise; + return this; + }); + + Assertion.addMethod('changeBalances', function (wallets, balanceChanges) { + const subject = this._obj; + if (typeof subject !== 'function') { + throw new Error(`Expect subject should be a callback returning the Promise + e.g.: await expect(() => wallet.send({to: '0xb', value: 200})).to.changeBalances(['0xa', '0xb'], [-200, 200])`); + } + const derivedPromise = getBalanceChanges(subject, wallets) + .then((actualChanges) => { + this.assert(actualChanges.every((change, ind) => change.eq(bigNumberify(balanceChanges[ind]))), + `Expected ${wallets.map((wallet) => wallet.address)} to change balance by ${balanceChanges} wei, but it has changed by ${actualChanges} wei`, + `Expected ${wallets.map((wallet) => wallet.address)} to not change balance by ${balanceChanges} wei,`, + balanceChanges.map((balanceChange) => balanceChange.toString()), + actualChanges.map((actualChange) => actualChange.toString())); + }); + this.then = derivedPromise.then.bind(derivedPromise); + this.catch = derivedPromise.catch.bind(derivedPromise); + this.promise = derivedPromise; + return this; + }); }; export default solidity; diff --git a/lib/utils.js b/lib/utils.js index 6b9b06586..813a4a116 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -13,3 +13,17 @@ export const eventParseResultToArray = (eventResult) => export const isWarningMessage = (error) => /: Warning: /.test(error); + +export const getBalanceChange = async (transactionCallback, wallet) => { + const balanceBefore = await wallet.getBalance(); + await transactionCallback(); + const balanceAfter = await wallet.getBalance(); + return balanceAfter.sub(balanceBefore); +}; + +export const getBalanceChanges = async (transactionCallback, wallets) => { + const balancesBefore = await Promise.all(wallets.map((wallet) => wallet.getBalance())); + await transactionCallback(); + const balancesAfter = await Promise.all(wallets.map((wallet) => wallet.getBalance())); + return balancesAfter.map((balancesAfter, ind) => balancesAfter.sub(balancesBefore[ind])); +}; diff --git a/test/matchers/balance.js b/test/matchers/balance.js new file mode 100644 index 000000000..6192253b9 --- /dev/null +++ b/test/matchers/balance.js @@ -0,0 +1,153 @@ +import chai, {AssertionError} from 'chai'; +import {createMockProvider, getWallets, solidity} from '../../lib/waffle'; +import {utils} from 'ethers'; + +chai.use(solidity); +const {expect} = chai; + +describe('Balance observers', () => { + let provider; + let sender; + let receiver; + + beforeEach(async () => { + provider = createMockProvider(); + [sender, receiver] = await getWallets(provider); + }); + + describe('Change balance, one account', () => { + it('Should pass when expected balance change is passed as string and is equal to an actual', async () => { + await expect(() => sender.sendTransaction({ + to: receiver.address, + gasPrice: 0, + value: 200 + })) + .to.changeBalance(sender, '-200'); + }); + + it('Should pass when expected balance change is passed as int and is equal to an actual', async () => { + await expect(() => sender.sendTransaction({ + to: receiver.address, + gasPrice: 0, + value: 200 + })) + .to.changeBalance(receiver, 200); + }); + + it('Should pass when expected balance change is passed as BN and is equal to an actual', async () => { + await expect(() => sender.sendTransaction({ + to: receiver.address, + gasPrice: 0, + value: 200 + })) + .to.changeBalance(receiver, utils.bigNumberify(200)); + }); + + it('Should pass on negative case when expected balance change is not equal to an actual', async () => { + await expect(() => sender.sendTransaction({ + to: receiver.address, + gasPrice: 0, + value: 200 + })) + .to.not.changeBalance(receiver, utils.bigNumberify(300)); + }); + + it('Should throw when expected balance change value was different from an actual', async () => { + await expect( + expect(() => sender.sendTransaction({ + to: receiver.address, + gasPrice: 0, + value: 200 + })) + .to.changeBalance(sender, '-500') + ).to.be.eventually.rejectedWith(AssertionError, `Expected "${sender.address}" to change balance by -500 wei, but it has changed by -200 wei`); + }); + + it('Should throw in negative case when expected balance change value was equal to an actual', async () => { + await expect( + expect(() => sender.sendTransaction({ + to: receiver.address, + gasPrice: 0, + value: 200 + })).to.not.changeBalance(sender, '-200') + ).to.be.eventually.rejectedWith(AssertionError, `Expected "${sender.address}" to not change balance by -200 wei`); + }); + + it('Should throw when not a callback is passed to expect', async () => { + expect(() => + expect(sender.sendTransaction({ + to: receiver.address, + gasPrice: 0, + value: 200 + })).to.changeBalance(sender, '-200') + ).to.throw(Error, `Expect subject should be a callback returning the Promise + e.g.: await expect(() => wallet.send({to: '0xb', value: 200})).to.changeBalance('0xa', -200)`); + }); + }); + + describe('Change balance, multiple accounts', () => { + it('Should pass when all expected balance changes are equal to actual values', async () => { + await expect(() => sender.sendTransaction({ + to: receiver.address, + gasPrice: 0, + value: 200 + })) + .to.changeBalances([sender, receiver], ['-200', 200]); + }); + + it('Should pass on negative case when one of expected balance changes is not equal to an actual value', async () => { + await expect(() => sender.sendTransaction({ + to: receiver.address, + gasPrice: 0, + value: 200 + })) + .to.not.changeBalances([sender, receiver], [-201, 200]); + await expect(() => sender.sendTransaction({ + to: receiver.address, + gasPrice: 0, + value: 200 + })) + .to.not.changeBalances([sender, receiver], [-200, 201]); + }); + + it('Should throw when expected balance change value was different from an actual for any wallet', async () => { + await expect( + expect(() => sender.sendTransaction({ + to: receiver.address, + gasPrice: 0, + value: 200 + })) + .to.changeBalances([sender, receiver], [-200, 201]) + ).to.be.eventually.rejectedWith(AssertionError, 'Expected 0x17ec8597ff92C3F44523bDc65BF0f1bE632917ff,0x63FC2aD3d021a4D7e64323529a55a9442C444dA0 to change balance by -200,201 wei, but it has changed by -200,200 wei'); + await expect( + expect(() => sender.sendTransaction({ + to: receiver.address, + gasPrice: 0, + value: 200 + })) + .to.changeBalances([sender, receiver], [-201, 200]) + ).to.be.eventually.rejectedWith(AssertionError, 'Expected 0x17ec8597ff92C3F44523bDc65BF0f1bE632917ff,0x63FC2aD3d021a4D7e64323529a55a9442C444dA0 to change balance by -201,200 wei, but it has changed by -200,200 wei'); + }); + + it('Should throw in negative case when expected balance changes value were equal to an actual', async () => { + await expect( + expect(() => sender.sendTransaction({ + to: receiver.address, + gasPrice: 0, + value: 200 + })).to.not.changeBalances([sender, receiver], [-200, 200]) + ).to.be.eventually.rejectedWith(AssertionError, `Expected 0x17ec8597ff92C3F44523bDc65BF0f1bE632917ff,0x63FC2aD3d021a4D7e64323529a55a9442C444dA0 to not change balance by -200,200 wei`); + }); + + it('Should throw when not a callback is passed to expect', async () => { + expect(() => + expect(sender.sendTransaction({ + to: receiver.address, + gasPrice: 0, + value: 200 + })).to.changeBalances([sender, receiver], ['-200', 200]) + ).to.throw(Error, `Expect subject should be a callback returning the Promise + e.g.: await expect(() => wallet.send({to: '0xb', value: 200})).to.changeBalances(['0xa', '0xb'], [-200, 200])`); + }); + }); +});