Smart Contract 테스트케이스 개발 Tips

Seungwon Go
ReturnValues
Published in
16 min readJan 20, 2019

By Seungwon Go, CEO & Founder at ReturnValues (seungwon.go@returnvalues.com)

이더리움 스마트컨트랙트를 개발하고 난 후 가장 중요한 것은 이더리움 네트워크에 배포하기 전에 충분한 테스트를 진행하는 것입니다. 그런데 대다수의 솔리디티 개발자는 자바스크립트에 익숙하지 않아서 테스트케이스 작성하는데 많은 어려움을 겪고 있습니다.

이번 포스팅에서는 Truffle 기반의 테스트케이스 작성시 유용한 Tip을 공유해 드리고자 합니다.

이 글을 읽기전에 먼저 포스팅 되었던 ‘Truffle : 스마트컨트랙트 테스트케이스 구현 방법’ 을 반드시 읽어 보시길을 추천 드립니다.

OpenZeppelin에서는 테스트 케이스 작성시 사용할 수 있는 유용한 모듈을 제공하고 있고, 이를 이용하면 좀 더 쉽게 테스트 케이스 작성이 가능해 집니다. 우리는 OpenZeppelin제공하는 openzeppelin-test-helpers(https://github.com/OpenZeppelin/openzeppelin-test-helpers)를 이용하는 방법을 알아보도록 하겠습니다.

Installation

npm install --save-dev openzeppelin-test-helpers

Usage

먼저 간단한 예제를 통해서 전체적으로 어떻게 사용되고 있는지 살편 본 후 각각의 모듈에서 제공하는 기능에 대해서 상세히 알아보도록 하겠습니다.

아래의 예제는 가장 기본적인 ERC20 토큰에 대한 테스트케이스 입니다.

const { BN, constants, expectEvent, shouldFail } = require('openzeppelin-test-helpers');

const ERC20 = artifacts.require('ERC20');

contract('ERC20', ([sender, receiver]) => {
beforeEach(async function () {
this.erc20 = ERC20.new();
this.value = new BN(1);
});

it('reverts when transferring tokens to the zero address', async function () {
await shouldFail.reverting(this.erc20.transfer(constants.ZERO_ADDRESS, this.value, { from: sender }));
});

it('emits a Transfer event on successful transfers', async function () {
const { logs } = this.erc20.transfer(receiver, this.value, { from: sender });
expectEvent.inLogs(logs, 'Transfer', { from: sender, to: receiver, value: this.value });
});

it('updates balances on successful transfers', async function () {
this.erc20.transfer(receiver, this.value, { from: sender });
(await this.token.balanceOf(receiver)).should.be.bignumber.equal(this.value);
});
});

첫번째 라인에서는 ‘openzeppelin-test-helpers’를 사용하기 위한 선언문입니다. 이때 테스트 케이스에서 사용할 모듈을 아래와 같이 선언하게 됩니다.

const { BN, constants, expectEvent, shouldFail } = require('openzeppelin-test-helpers');

여기서는 BN, constants, expectEvent, shouldFail을 사용할 것으로 선언되었습니다.

위에서 제시된 코드를 이해하기 전에 ‘openzeppelin-test-helpers’에서 제공하는 모듈은 어떤것이 있는지 알아보도록 하겠습니다.

balance

특정 account의 ether 잔액을 다루는 모듈입니다.

  • async balance.current (account) : account의 현재 잔액을 반환합니다.
  • async balance.difference (account, promiseFunc) : account에 promiseFunc이 수행되고 난후 바뀐 잔액을 반환합니다.

difference의 사용법에 대해서 간단히 살펴보도록 하겠습니다.

(await balance.difference(receiver, () =>
send.ether(sender, receiver, ether('1')))
).should.be.bignumber.equal(ether('1'));

위의 코드에서 promiseFunc을 보시면, sender가 receiver에게 1 ether를 전송하고 있습니다. 이 function이 실행이 되면, receiver의 잔액은 원래 가지고 있는 잔액보다 1 ether 만큼 더한 잔액을 가진게 됩니다.

주로 ether를 특정 account로 전송을 해보고, 해당 account로 전송된 만큼의 잔액이 있는지 확인할때 많이 사용하게 됩니다.

BN

BigNumber을 다루는 모듈입니다.

const RATE = new BN(10);

자바스크립트(최대 32 비트)와 솔리디티(최대 uint256)에서 다루는 숫자형 변수가 다룰수 있는 길이가 다르기 때문에, 자바스크립트에서는 BigNumber(실제는 문자열로 숫자를 처리)라는 모듈을 이용하여 솔리디티의 uint의 연산을 처리하게 됩니다.

주로 솔리디티 프로그램으로 숫자형 타입을 파라미터로 입력할 때 사용하게 되며, 솔리디티에서 반환한 uint 값을 받기 위해 사용하게 됩니다.

ether

ether의 단위를 변경할 수 있는 모듈입니다. ether을 wei로, 혹은 wei를 ether로 단위 변환을 하여 사용할 경우가 많은데, 이 모듈은 단위변환을 쉽게 할 수 있도록 해줍니다.

const GOAL = ether('10');

솔리디티는 소수점(decimal)를 다루지 않습니다. 그래서 ether의 경우 단위가 소수점 18자리까지로 되어 있지만, 솔리디티 프로그램 내에서는 소수점을 ㄷ루지 않기 때문에 1 ether의 경우는 1000000000000000000 로 저장이 되어집니다.

테스트케이스 작성시 매번 decimal를 고려해서 코드를 작성하기는 매우 불편합니다. 그래서 ether 모듈은 이렇게 ether의 단위 변환을 손쉽게 할 수 있도록 지원합니다.

주로 솔리디티 프로그램으로 ether값을 전달할때 사용하게 됩니다.

expect

expect는 chai-bignumber의 property를 포함하고 있어서, BigNumber의 비교시 사용하게 됩니다.

expect(new BN('2')).to.be.bignumber.equal('2');

앞서 얘기한 것 처럼 자바스크립트와 솔리디티에서 다룰수 있는 숫자형 변수의 길이가 같이 않습니다.

expect는 자바스크립트에서 솔리디티 프로그램 내에 저장된 숫자형 변수 값을 비교할 때 사용합니다.

expectEvent

스마트컨트랙트에서 특정 function이 수행되고 생기는 Event를 비교하기 위해서 사용되는 모듈입니다.

  • inLogs (logs, eventName, eventArgs={}) : 발생한 event의 event이름과 arguments가 일치하는지 비교할 수 있습니다.
it('emits a Transfer event on successful transfers', async function () {
const { logs } = this.erc20.transfer(receiver, this.value, { from: sender });
expectEvent.inLogs(logs, 'Transfer', { from: sender, to: receiver, value: this.value });
});
  • async function inConstruction (contract, eventName, eventArgs={}) : contract 생성시 최초의 실행되는 construction에서 발생하는 event를 비교할 수 있습니다.
const contract = await MyContract.new(5);
await expectEvent.inConstruction(contract, 'Created', { value: 5 });
  • async inTransaction (txHash, emitter, eventName, eventArgs={}) : 특정 transaction(txHash)에 대해서 inLogs와 동일한 기능을 합니다.

send

ether를 전송하는 기능을 제공한 모듈입니다.

  • async send.ether (from, to, value) : from에서 to로 value을 전송하는 기능
send.ether(sender, receiver, ether('1')))
  • async function send.transaction (target, name, argsTypes, argsValues, opts={}) :

should

expect와 동일하게 chai-bignumber의 property를 포함하고 있어서, BigNumber의 비교시 사용하게 됩니다.

(await this.crowdsale.openingTime()).should.be.bignumber.equal(this.openingTime);

shouldFail

Fail을 가정한 비교 모듈입니다.

  • async shouldFail.reverting (promise) : require와 같은 EVM revert로 발생된 fail 비교할수 있다.
await shouldFail.reverting(this.crowdsale.send(ether('1')));
  • async shouldFail.reverting.withMessage (promise, message) : shouldFail.reverting과 동일한데, revert를 통해 발생한 message를 비교할 수 있다.

contract 코드가 아래와 같다면,

contract Owned {
address private _owner;

constructor () {
_owner = msg.sender;
}

function doOwnerOperation() public view {
require(msg.sender == _owner, "Unauthorized");
....
}
}

테스트케이스는 아래처럼 작성이 가능하다.

it('Fails when called by a non-owner account', async function () {
await shouldFail.reverting.withMessage(this.owned.doOwnerOperation({ from: other }), "Unauthorized");
});
  • async shouldFail.throwing (promise) : assert (which executes an invalid opcode)로 발생한 fail를 비교할 수 있다.
  • async shouldFail.outOfGas (promise) : 실행하는 transaction이 out of gas로 발생되는 fail를 비교할 수 있다.

time

솔리디티에서는 datetime 형식이 데이터 타입은 존재하지 않고, datetime 역시 uint(unix timestamp)로 관리되어집니다. 그래서 자바스크립트에서 datetime 형식의 솔리디티 값을 비교하는 것이 쉽지 않은데, time은 unix timestamp을 손쉽게 다룰수 있도록 해주는 모듈입니다.

  • async time.advancedBlock() : block height를 강제로 증가 시켜줍니다.
  • async time.latest() : 가장 최근에 생성된 block의 timestamp을 반환합니다.
  • async time.latestBlock() : 가장 최근에 생성된 block number를 반환합니다.
  • async time.increase(duration) : duration (in seconds)만큼 증가 시킵니다.
  • async time.increaseTo(target) : target에 지정된 time으로 현재 시간을 증가 시켜줍니다.
  • async time.duration : 초, 분, 시간, 일, 주, 년단위로 시간을 쉽게 증가 시킬 수 있도록 해줍니다. seconds, minutes, hours, days, weeks , years.

예를 들어 ICO를 위한 crowdsale 컨트랙트를 개발한다고 가정해 봅시다. 기본적으로 crowdsale은 시작일과 종료일이 있습니다. 테스트케이스 내에서 시작일과 종료일은 아래와 같이 time 모듈을 이용해서 아래와 같이 작성할 수 있습니다.

this.openingTime = (await time.latest()).add(time.duration.weeks(1));    
this.closingTime = this.openingTime.add(time.duration.weeks(1)); this.afterClosingTime = this.closingTime.add(time.duration.seconds(1));

테스트 케이스 예제

다시 제일 위에서 예로 들었던 테스트케이스를 살펴보도록 하겠습니다.

const { BN, constants, expectEvent, shouldFail } = require('openzeppelin-test-helpers');

const ERC20 = artifacts.require('ERC20');

contract('ERC20', ([sender, receiver]) => {
beforeEach(async function () {
this.erc20 = ERC20.new();
this.value = new BN(1);
});

it('reverts when transferring tokens to the zero address', async function () {
await shouldFail.reverting(this.erc20.transfer(constants.ZERO_ADDRESS, this.value, { from: sender }));
});

it('emits a Transfer event on successful transfers', async function () {
const { logs } = this.erc20.transfer(receiver, this.value, { from: sender });
expectEvent.inLogs(logs, 'Transfer', { from: sender, to: receiver, value: this.value });
});

it('updates balances on successful transfers', async function () {
this.erc20.transfer(receiver, this.value, { from: sender });
(await this.token.balanceOf(receiver)).should.be.bignumber.equal(this.value);
});
});

위의 코드를 살펴보시면, new BN(1), shouldFail.reverting, expectEvent.inLogs, should.be.bignumber 이렇게 4가지를 사용한것을 보실 수 있습니다.

shouldFail.reverting을 코드를 보시면, 실제 존재하지 않는 address로 토큰을 전송하려고 하면 revert가 일어나도록 프로그램 되어져 있어야 하므로, Fail을 예상하고 작성된 테스트 케이스 입니다.

shouldFail.reverting(this.erc20.transfer(constants.ZERO_ADDRESS, this.value, { from: sender }));

expecEvent.inLogs 코드를 보시면, ERC20 토큰에서 transfer 메소드가 실행이 되면, Transfer라는 이벤트가 발생되게 되어 있습니다. 그래서 transfer 메소드를 실행 시킨 후 Transfer라는 이벤트가 정상적으로 발생이 되는지를 테스트 하고 있습니다.

const { logs } = this.erc20.transfer(receiver, this.value, { from: sender });
expectEvent.inLogs(logs, 'Transfer', { from: sender, to: receiver, value: this.value });

마지막으로 receiver에게 this.value를 전송한 후 receiver의 잔액이 전송된 토큰 만큼 증가했는지를 should.be.bignumber로 비교하고 있습니다.

this.erc20.transfer(receiver, this.value, { from: sender });
(await this.token.balanceOf(receiver)).should.be.bignumber.equal(this.value);

여기까지 잘 따라 오셨다면, 테스트케이스를 좀더 효율적으로 작성하는 방법에 대해서 어느정도 이해가 되셨을거라 생각합니다.

openzeppelin-solidity(https://github.com/OpenZeppelin/openzeppelin-solidity/tree/master/test)에 보시면 작성된 스마트컨트랙트 코드에 대한 테스트 케이스가 아주 상세하게 구현되어져 있습니다. 최소한 여기에 있는 코드들은 반드시 리뷰해 보시기를 추천 드립니다.

--

--