Smart Contract Unit Test

본격적으로 스마트 컨트랙트 기능을 테스트하겠습니다.

스마트 컨트랙트의 기능을 테스트한다고 했는데, 무엇을 테스트해야 할지 막막합니다. 그러나 테스트는 누가 정해주지 않습니다. 개발자가 직접 다양한 상황을 고려해서 되도록 많은 것을 테스트해야 합니다. 테스트로는 다음과 같은 것을 생각할 수 있습니다.

  • 컨트랙트가 배포되어 주소가 존재하는가?
  • 컨트랙트를 생성할 때 초기값은 제대로 저장되는가?
  • contribute 함수가 호출되면 컨트랙트의 balance가 증가하는가?
  • approvalWithdrawal 함수가 호출되면 approvals_count가 증가하는가?
  • finalizeWithdrawal 함수가 호출되면 author로 금액이 송금되는가?

이전에 Remix로 대부분 테스트한 내용입니다. 여기서는 mocha 테스트 프레임워크를 사용해서 테스트 해보겠습니다.


Deployed Contract Address

앞서 테스트 환경을 구축하고 가장 먼저 deploy된 smart contract object를 출력하는 테스트를 함께했었습니다. 사실 이건 테스트는 아니고, 테스트가 어떻게 돌아가는지만 본 것입니다. 다음 코드를 보면 두 번째 테스트로 deploy된 컨트랙트의 주소가 있는지를 검사합니다. 당연히 deploy됐으면 주소가 있어야죠.


// test groups
describe( 'DreamStory', () => {
    // unit test: simply print out the deployed contract object
    it( 'Deploy a DreamStory contract', () => {
      // print out deployed contract object
      console.log( dream_story );
    });
    // unit test: contract address exists
    it( 'Deployed contract address', () => {
      // use assert.ok function to check if the address exists
      assert.ok( dream_story.options.address );
      // print out the address
      console.log( dream_story.options.address );
    });
});

  • assert.ok 함수는 인자가 존재하면 true를 반환
  • deploy된 컨트랙트는 options 객체를 가지고 options객체는 address변수를 가짐.

다음과 같이 테스트를 실행합니다.

$ npm run test


테스트 결과는 다음과 같습니다.


deploy된 주소 0x4f84D3C670F443feb9D986f86AB9862D1ceE2176가 콘솔에 표시됐습니다.


Minimum Download Price in Wei

DreamStory 컨트랙트를 배포할 때 Minimum Download Price in Wei라는 값을 넣게 되어 있습니다. author가 지정하는 값으로, contributor가 DreamStory를 다운로드 할 때 최소한으로 지불해야 하는 금액을 설정하는 것입니다. 테스트 코드에서 100wei로 설정하고 컨트랙트 배포한 후 배포된 컨트랙트에 접속하여 값을 읽어보는 테스트를 진행하겠습니다.

컨트랙트 생성 시 입력할 값을 변수 INIT_MIN_DOWN_PRICE로 다음과 같이 설정합니다. 그리고 beforeEach 내용도 전과 달리 약간 변경되었습니다. 컨트랙트 deploy 함수의 인자로 위 변수를 대입했습니다.



테스트 코드는 새롭게 추가되는 부분만 표시하겠습니다. 여러분은 위 내용을 앞서 소개한 테스트 블록에 직접 코딩해야 합니다.

// unit test: check if the minimum download price is the same as the input
    it( 'Check minimum download price', async () => {
      // get the minimum_down_price_wei of the contract
      // this uses await since it needs to connect the Contract
      // to get the value of state variable, need to use call() function
      const min_down_price_wei= await dream_story.methods.min_down_price_wei().call();
      // check if the state variable min_down_price_wei is equal to the intial value
      assert.equal( min_down_price_wei, INIT_MIN_DONW_PRICE );
      // print out the min_down_price_wei
      console.log( min_down_price_wei );
    })

컨트랙트에 접속하기 때문에 asyn 함수로 선언하고, await로 함수 호출 결과를 기다림입니다. 컨트랙트의 상태변수는 기본적으로 값을 반환하는 함수가 만들어집니다. 여기서는 min_down_price_wei()죠.

이처럼 함수 호출하고 끝나는게 아니라 .call() 함수를 호출해야만 상태 변수를 읽어옵니다.

참고로, call() 함수는 gas를 소모하지 않습니다. 반면에 상태변수를 바꾸는 .send() 함수의 경우는 gas를 소모하죠.

assert.equal 함수는 먼저 테스트하고자 하는 변수를 쓰고, 그 변수가 가져야 할 값을 씁니다. 두 개의 값이 같으면 true를 반환합니다.

그럼 테스트 결과를 함께 봅시다.


컨트랙트 배포할 때 입력한 100wei가 제대로 설정됐습니다.


contribute 함수 테스트

테스트 작업을 하다보니 빠진 부분을 발견했습니다. contribute 함수를 수행할 때 입력한 금액이 0보다 큰 경우만 contributor로 받아들이는 코드를 추가해야 했었죠. 다음과 같이 추가합시다.

/*
     * A contributor donates some money for a dream story.
     * So the money will be transfered to this contract address, resulted in increasing the balance
     * @note this function can receive some money, which is msg.value
     */
    function contribute() public payable {
        // check if the money is greater than zero
        require( msg.value > 0 );
        // increase the vote counts
        votes_count++;
        // set contributor address to true
        contributors[ msg.sender ]= true;
    }


컨트랙트 코드를 변경하면 반드시 compile.js 스크립트를 실행해야 합니다. 다음과 같이 compile.js가 위치한 곳을 이동하여 스크립트를 실행합니다.

$ cd ethereum
$ node compile.js


contribute 함수를 테스트하기 위해 별도의 테스트 블록을 만듭니다. 굳이 별도의 블록을 만들지 않아도 되는데, 그룹화하면 테스트 구별하기도 좋고 충분한 테스트가 되었는지 살펴보기도 좋습니다.
여기서 유저가 contribute 함수를 호출했을 때 테스트할 것은 세 가지입니다.

  • votes_count가 증가하는가?
  • 유저는 contributor list에 등록되는가?
  • contribute한 금액이 컨트랙트의 balance로 송금되는가?


다음과 같이 새롭게 테스트 그룹을 만듭니다.

// test contribution value in ether
const INIT_CONTRIBUTE_ETH= '1';
// test groups: contribute tests
describe( 'DreamStory contribute function tests', () => {
  // unit test: check if the votes_count increase when a user contributes
  //            now the user should be inserted to contributor list
  //            the balance of the contract should increase
  it( 'Check votes count and others', async () => {
    // get the intial balance of the contract to test balance, which is in string
    let init_balance= await web3.eth.getBalance( dream_story.options.address );
    // convert the wei into ether
    init_balance= web3.utils.fromWei( init_balance, 'ether' );
    // cast the string to float
    init_balance= parseFloat( init_balance );
    // use the second account to contribute
    await dream_story.methods.contribute().send( {
      from: accounts[1],
      // amount to contribute. convert the ether into wei
      value: web3.utils.toWei( INIT_CONTRIBUTE_ETH, 'ether' ),
      // set gas limit`
      gas: '1000000'
    });
    // get the votes_count of the deployed contract
    const votes_count= await dream_story.methods.votes_count().call();
    // check if the votes_count increased
    assert.equal( votes_count, 1 );
    // print out the votes_count
    console.log( votes_count );
    // get the mapping value of the user account
    const is_contributor= await dream_story.methods.contributors( accounts[1] ).call();
    // assert the result
    assert( is_contributor );
    console.log( is_contributor );
    // get the balance of the author now. note that the balance is in string
    let balance= await web3.eth.getBalance( dream_story.options.address );
    // first convert the balance into ether
    balance= web3.utils.fromWei( balance, 'ether' );
    // cast the string to float
    balance= parseFloat( balance );
    console.log( init_balance );
    console.log( balance );
    // check if it is increased
    assert( balance > init_balance );
  })
});

contribution을 하기 전에 스마트 컨트랙트의 balance를 읽어서 저장합니다. accounts[1]의 계정을 이용하여 contribution 금액을 200wei로 하여 contribute 수행합니다. contribution 후에 컨트랙트의 balance를 읽어서 별도의 변수에 저장합니다.

  • 테스트① : contributor의 수를 나타내는 votes_count가 1이 되는지 확인
  • 테스트 ②: accounts[1]이 contributor인지 확인
  • 테스트 ③: contribution 후에 컨트랙트 balance가 증가하는지 확인

테스트 실행 결과는 다음과 같습니다.



실행 결과에서 첫번째로 출력된 숫자 '1'은 votes_count 값입니다. 두 번째 값 'true'는 accounts[1]이 contributor인지를 의미합니다. 세 번째와 네 번째 숫자는 각각 contribution 전후의 스마트 컨트랙트 balance 값입니다. 컨트랙트이 balance가 0에서 200 wei로 증가했네요.

assert( balance > init_balance+INIT_CONTRIBUTE_WEI-1 ); 처럼 부등호로 테스트한 이유는 숫자의 등호 검증은 숫자가 표시되는 형식에 따라 다를 수 있기 때문입니다. 특히나 소수점을 비교할 때는 특히나 등호 대신 부등호 사용 필요합니다.


오늘의 실습: contribute 함수에서 빼먹은 테스트는 뭐가 있을까요?


앞서 contribute함수의 테스트를 진행했습니다. 함수가 매우 간단하여 더 진행할 테스트는 생각이 나지 않네요. 스마트 컨트랙트는 매우 간결하고 알기 쉽게 작성해야 합니다! 왜냐구요? 복잡하면 사람들이 신뢰할 수 없고, 비용 소모도 크기 때문입니다!

자, 이어서 다음 함수를 테스트해 보겠습니다.


download 함수 테스트

download 함수 테스트는 앞서 진행한 contribute 함수 호출이 필요합니다. 왜냐하면 download는 contributor만 가능하기 때문입니다. 따라서 앞서 진행한 코드를 중복해서 사용하겠습니다. 즉, 새롭게 describe 테스트 블록을 만듭니다. 테스트할 내용은 다음과 같습니다.

  • downloads 수는 증가하는가?
  • contribute만 download 함수를 실행할 수 있는가?
  • 이미 다운로드한 contributor가 다시 다운로드하는 것을 막아주는가?
  • 다운로드한 contributor는 downloads 배열에 저장되는가?
  • contributor가 최소 다운로드 금액보다 작게 입력하면 다운로드는 실패하는가?


테스트 항목을 생각하다 보니 스마트 컨트랙트의 소스 코드에 추가할 부분이 생겼습니다. download 함수가 실행되면 contributor가 설정한 금액이 컨트랙트로 전송되어야 합니다. 스마트 컨트랙트 소스 코드에서 download 함수를 다음과 같이 수정합니다.

    /*
     * Download (license of) the dream story
     * @note this function can receive some money, which is msg.value
     */
    function download() public payable onlyContributor {
        // check if the contributor has downloaded before.
        // if so, no need to download again
        require( !downloaders[msg.sender] );
        // check if the input price is bigger than the min_down_price_wei
        require( msg.value >= min_down_price_wei );
        // local variable is basically stored in storage,
        // and literal such as struct is created in memory since it is temporary.
        // storage variable references the original variable
        // memory variable copies the original variable
        // memory is temporary, storage is global.
        Download memory new_download= Download({
           downloader: msg.sender,
           price_wei: msg.value,
           date: now
        });

        // add it to the downloads array
        downloads.push( new_download );
        // set the download address to true
        downloaders[ msg.sender ]= true;
    }


컨트랙트 코드를 변경하면 반드시 compile.js 스크립트를 실행해야 합니다. 다음과 같이 compile.js가 위치한 곳으로 이동하여 스크립트를 실행합니다.

$ cd ethereum
$ node compile.js


contributor 아닌 계정으로 download 시도

가장 먼저 contributor가 아닌 계정으로 다운로드를 시도하는 테스트를 하겠습니다. 당연히 오류가 발생해야 합니다. 테스트 코드에서는 오류가 발생하면 테스트가 성공하는 방식으로 작성되었습니다. 헷갈리지 마세요!

// test download value in ether
const INIT_DOWNLOAD_ETH= '2';
// test groups: download tests
describe( 'DreamStory download function tests', () => {
  //// test: try to download using a not contributor's account
  it( 'Downloader test', async () => {
    // call download using account[2] which is not a contributor
    try {
      await dream_story.methods.download().send( {
        from: accounts[2],
        // amount to download. convert ether to wei
        value: web3.utils.toWei( INIT_DOWNLOAD_ETH, 'ether' ),
        // gas limit to execute this function
        gas: 1000000
      });
      assert( false );
    }
    catch( error ) {
      // if error occurs, this assert will pass, which is intended
      assert( true );
    }
  });

contribution하지 않은 accounts[2] 계정으로 download 함수를 호출했을 때 try -catch 문에서 에러가 발생합니다. 테스트는 assert( true )에 의해 pass됩니다. 테스트 결과는 생략하겠습니다.



Warning

download 함수를 호출할 때 gas를 명시하지 않으면 out of gas 오류가 발생할 수 있습니다. gas를 명시하지 않으면 gas limit(해당 함수를 수행할 때 max로 지불하고자 하는 gas량)을 자동으로 계산하나, 이 경우는 그 계산값이 틀렸는지 out of gas에러가 발생했습니다.

이전 contribute 함수 테스트에서도 gas를 명시하지 않았는데, 가능하면 명시하는 게 좋습니다.



download count, double download, downloader

다음 네 가지 테스트를 동시에 진행해 보겠습니다.

  • 테스트 ①: download 함수 실행 후 download 수 증가
  • 테스트 ②: download 계정이 downloaders에 추가
  • 테스트 ③: 이미 download한 계정이 다시 download
  • 테스트 ④: contributor가 minimum download price보다 적게 송금

참고로 ①번의 테스트 블록(describe) 내에 다음 코드가 있어야 합니다. 테스트 블록 내에서 개별적으로 수행될 수 있는 테스트는 'it'으로 분리했습니다. 분리해도 다음 코드는 꽤 깁니다. 테스트를 진행하기 위해 사전에 필요한 것들이 많기 때문입니다.


  ////// tests: increment of the number of downloads
  //            downloaders array
  //            double download
  //            minimum download price
  it( 'Check downloads count and others', async () => {
    // get the intial balance of the c to test balance
    let init_balance= await web3.eth.getBalance( dream_story.options.address );
    // convert to ether
    init_balance= web3.utils.fromWei( init_balance, 'ether' );
    // cast string to float
    init_balance= parseFloat( init_balance );
    // print out initial balance
    console.log( init_balance );
    // use the second account to contribute
    await dream_story.methods.contribute().send( {
      from: accounts[1],
      // amount to contribute. convert the ether into wei
      value: web3.utils.toWei( INIT_CONTRIBUTE_ETH, 'ether' ),
      // gas limit to execute this function
      gas: 1000000
    });

    //// test: increment of the number of downloads
    // call download using a contributor's account
    await dream_story.methods.download().send( {
      from: accounts[1],
      // amount to download. convert ether to wei
      value: web3.utils.toWei( INIT_DOWNLOAD_ETH, 'ether' ),
      // gas limit to execute this function
      gas: 1000000
    });
    // get the summary
    const summary= await dream_story.methods.getSummary().call();
    // check if the number downloads is increased
    assert.equal( summary[2], 1 );
    // print out the votes_count
    console.log( summary[2] );

    //// test: downloader check
    // get the mapping value of the account
    const is_downloader= await dream_story.methods.downloaders( accounts[1]  ).call();
    // assert the result
    assert( is_downloader );
    console.log( is_downloader );

    //// test: block double downloading
    // call download using the address of a downloader
    try {
      await dream_story.methods.download().send( {
        from: accounts[1],
        // amount to download. convert ether to wei
        value: web3.utils.toWei( INIT_DOWNLOAD_ETH, 'ether' ),
        // gas limit to execute this function
        gas: 1000000
      });
      assert( false );
    }
    catch( error ) {
      // if error occurs, this assert will pass, which is intended
      assert( error );
      console.log( true );
    }

    //// test: minimum download price`
    // call download with less amount of money than the miminum
    try {
      const min_down_price= await dream_story.methods.min_down_price_wei().call();
      const down_price= min_down_price - 10;
      await dream_story.methods.download().send( {
        from: accounts[1],
        // amount to download. convert ether to wei
        value: down_price,
        // gas limit to execute this function
        gas: 1000000
      });
      assert( false );
    }
    catch( error ) {
      // if error occurs, this assert will pass, which is intended
      assert( error );
      console.log( true );
    }
  })
}); // end of describe( 'DreamStory download function tests' ... )

download한 계정의 balance 변화는 테스트하지 않습니다. accounts[1]로 contribute 함수를 먼저 실행하고 이후 download 함수를 실행합니다. 결괏값을 얻기 위해 getSummary 함수를 사용했습니다.

  • 테스트 ①: downloads의 개수는 getSummary의 반환값 중 3번째입니다. 즉, index로는 2인 값을 읽어서 비교합니다.
  • 테스트 ②: download한 accounts[1] 계정이 downloaders에 추가되었는지 체크합니다.
  • 테스트 ③: accounts[1]로 download한 상태에서 또 다시 download를 호출합니다. try-catch의 error가 발생합니다.
  • 테스트 ④: accounts[1]로 minimum보다 10wei 작게 입력하여 download를 호출합니다. try-catch의 error가 발생합니다.


테스트 결과는 다음과 같습니다.

첫 번째 0은 컨트랙트의 초기 balance 값입니다. 두 번째 1은 downloads 횟수, 세 번째 true는  ccounts[1]가 downloaders에 속한다는 의미입니다.

네 번째 true는 중복 download 방지에 성공했음을 의미하죠. 다 섯번째 true는 최소 금액 이하로 download를 금지하는 데 성공했단 뜻입니다.

try-catch 문에서 assert( error )는 error가 발생하여 error 객체가 존재하기 때문에 assert( error )값은 true가 되었습니다.


스마트 컨트랙트 코딩이 성급하게 했는지, 몇 가지 오류가 있어서 테스트 하면서 약간 수정을 했습니다. 이 글을 보는 분은 참고하길 부탁드립니다. 역시나 테스트가 왜 필요한지 절실히 느끼게 되었습니다. 매우 짧아 보이는 테스트이지만 오류 수정과 개념 정립하느라 3-4시간이 훌쩍 지나갔네요.

그럼 다음에는 나머지 함수도 테스트하겠습니다. 어째 다음 것들도 수정할 게 생기겠네요.



오늘의 실습: 스마트 컨트랙트 코딩을 먼저 하지 않고, 테스트를 먼저 구상하면서 컨트랙트를 디자인 해보세요. 이 방식이 test driven development coding 방식입니다.


아직 테스트하지 못한 함수가 몇 개 있습니다. 테스트를 진행하며 코드가 추가되거나 변경될 수 있습니다. Test Driven Development 코딩 방식이 개발시간을 단축시켜준다는 말은 많이 봐왔는데, 이렇게 차근히 테스트를 바탕으로 코딩을 진행하니 정말 코드가 단단해지고, 나중에 발생할 오류와 버그가 현저히 줄거 같은 기분이 들어 좋습니다!


approveWithdrawal 함수 테스트

approveWithdrawal 함수는 contributor가 컨트랙트에 누적된 금액을 인출하는 것에 동의하는 함수입니다. contributor 각각이 별도로 실행하는 함수입니다. 이 함수에 의해 코인이 전송되는 것은 아닙니다. 어떤 contributor가 인출에 동의했는지, 동의한 사람은 몇 명인지를 관리합니다. 테스트할 내용은 다음과 같습니다.

  • 테스트 ①: 함수를 호출한 계정은 contributor인가?
  • 테스트 ②: 함수를 호출한 계정은 이미 approve했는가?
  • 테스트 ③: approve를 하면 approvals_count는 증가하는가?
  • 테스트 ④: approve를 하면 해당 계정은 approvals 리스트에 등록되는가?

어떤 것을 테스트할 것인가는 전적으로 개발자가 정해야 합니다. 조금이라도 코드를 확인하고 싶은 것이 있다면 테스트에 추가해야 합니다.

이번에도 몇가지 스마트 컨트랙트 소스 코드를 변경합니다. 단순히 상태변수의 이름을 바꿨습니다. 왜 굳이 이름을 바꿨는지 의아할 수도 있는데, 코드를 봤을 때 변수가 의미하는 바를 바로 알 수 있게끔 해야 좋은 코드이기 때문입니다. 이전에 승인자의 개념으로 approvals이란 변수명을 썼는데, 이것은 승인자 계정이란 정보를 의미하지 않아서, 분명하게 approvers라고 하여 계정을 의미하도록 바꿨습니다. 다음과 같이 코드를 수정합니다. 수정 부분은 상태변수 선언부와 함수입니다.

// list of approvers
 mapping( address => bool ) public approvers;
 // number of approvers
 uint public approvers_count;

 (중간 생략)

function approveWithdrawal() public onlyContributor {
  // check whether this contributor approved the withdrawl already
   require( !approvers[msg.sender] );
   // set the account as an approver
   approvers[ msg.sender ]= true;
   // increase the counts
   approvers_count++;
}


컨트랙트 코드를 변경하면 반드시 compile.js 스크립트를 실행해야 합니다. 다음과 같이 compile.js가 위치한 곳으로 이동하여 스크립트를 실행합니다.

$ cd ethereum
$ node compile.js


또 한 가지 오류를 발견했습니다! 바로 compile.js 파일 자체입니다. 컨트랙트 소스를 변경하고 compile.js를 호출했는데, 여기서 컴파일 결과가 표시되지 않았습니다. 당연히 제대로 된줄 알았는데, 코드의 오류로 compile이 제대로 안 되었지만, 어떤 에러 메시지도 뜨지 않았습니다. Remix에 코드를 붙여넣었다면 오류를 알 수 있었겠지만, 별도의 compile.js를 사용하는데 경우에는 컴파일 실패를 알려주지 않습니다. 따라서 compile.js 파일에 다음과 같이 코드를 추가합니다.

// import assert module to test with assertion
const assert= require( 'assert' );

(중간 생략)

// compiled output, extract only contracts part
console.log( 'now compiling the contract code ...' );
const compile_output= solc.compile( contract_src, 1 ).contracts;
assert( compile_output[':DreamStory'] );
console.log( 'compiled' );

컴파일이 실패하면 assert 문에서 잡아낼 것입니다. assert 인자로 쓰인 :DreamStory는 contract 이름입니다. 주의할 것은 앞에 :가 붙어 있는 점입니다. 이것은 솔리디티 컴파일러에 의해 만들어진 것이라 이렇게 해줘야 합니다. 이렇게 해도 컴파일 실패했는지만 알고, 어디서 실패했는지는 알 수 없습니다. 따라서 Remix로 코드를 복사해서 코드 오류를 살펴봐야 합니다.


1. contributor가 아닌 계정으로 approveWithdrawal 실행

먼저 contributor가 아닌 계정으로 다운로드를 시도해 보겠습니다. 당연히 오류가 발생해야 합니다. 테스트 코드에서는 오류가 발생하면 테스트가 성공하는 방식으로 작성되었습니다.
다음과 같이 새롭게 테스트 블록을 만듭니다. 내용은 download 함수 테스트와 같습니다.

// test groups: approveWithdrawal tests
describe( 'DreamStory approveWithdrawal function tests', () => {
  //// test: try to approve using a not contributor's account
  it( 'approver test', async () => {
    // call approveWithdrawal using account[2] which is not a contributor
    try {
      await dream_story.methods.approveWithdrawal().send( {
        from: accounts[2],
        // gas limit to execute this function
        gas: 1000000
      });
      assert( false );
    }
    catch( error ) {
      // if error occurs, this assert will pass, which is intended
      assert( error );
      console.log( 'true' );
    }
  });
});

contribution하지 않은 accounts[2] 계정으로 approveWithdrawal 함수를 호출했을 때 try -catch 문에서 에러가 발생합니다. 이 함수는 상태변수의 값을 바꾸기 때문에 gas가 소모됩니다. 따라서 적절한 gas limit을 입력해야 합니다.

테스트는 assert( error )에 의해 pass. error object가 존재하기 때문에 assert 결과는 true가 됩니다. 테스트 결과는 생략하겠습니다.


2. approvers_count, double approvals, approvers 등록 테스트

다음 세 가지 테스트를 동시에 진행해보겠습니다.

  • 테스트 ①: approveWithdrawal 함수 실행 후 approvers_count 수가 증가
  • 테스트 ②: approve한 계정이 approvers에 추가
  • 테스트 ③: 이미 approver가 다시 approve 수행

참고로 1번의 테스트 블록(describe) 내에 다음 코드가 있어야 합니다. 테스트 블록 내에서 개별적으로 수행될 수 있는 테스트는 'it'으로 분리했습니다.

////// tests: increment of the number of approvers
  //            approvers list
  //            double approvals
  it( 'Check approvers count and others', async () => {
    // use the second account to contribute
    await dream_story.methods.contribute().send( {
      from: accounts[1],
      // amount to contribute. convert the ether into wei
      value: web3.utils.toWei( INIT_CONTRIBUTE_ETH, 'ether' ),
      // gas limit to execute this function
      gas: 1000000
    });

    //// test: increment of the number of approvers
    // call approveWithdrawal using a contributor's account
    await dream_story.methods.approveWithdrawal().send( {
      from: accounts[1],
      // gas limit to execute this function
      gas: 1000000
    });
    // get the summary
    const summary= await dream_story.methods.getSummary().call();
    // check if the number of approvers is increased
    assert.equal( summary[4], 1 );
    // print out the approvers_count
    console.log( summary[4] );

    //// test: approver check
    // get the mapping value of the account
    const is_approver= await dream_story.methods.approvers( accounts[1] ).call();
    // assert the result
    assert( is_approver );
    console.log( is_approver );

    //// test: block double approvals
    // call approveWithdrawal using the approver's account
    try {
      await dream_story.methods.approveWithdrawal().send( {
        from: accounts[1],
        // gas limit to execute this function
        gas: 1000000
      });
      assert( false );
    }
    catch( error ) {
      // if error occurs, this assert will pass, which is intended
      assert( error );
      console.log( 'true' );
    }
  });

accounts[1]으로 contribute 함수를 먼저 실행하고, 이후에 approveWithdrawal 함수 실행합니다. 결괏값을 얻기 위해 getSummary 함수 사용했습니다.

  • 테스트 ①: approvers의 개수는 getSummary의 반환값 중 5번째입니다. 즉, index로는 4인 값을 읽어서 비교합니다.
  • 테스트 ②: approve한 accounts[1] 계정이 approvers에 추가되었는지 체크합니다.
  • 테스트 ③: accounts[1]로 approve한 상태에서 또 다시 approveWithdrawal 호출합니다. try-catch의 error 발생해야 합니다.

테스트 결과는 다음과 같습니다.

첫 번째 1은 approvers 갯수고, 두 번째 true는 accounts[1]가 approvers에 속한다는 의미입니다. 세 번째 true는 중복 approveWithdrawal 방지 성공했다는 뜻입니다.



오늘의 실습: 여러분이 작성한 코드의 변수명은 의미하는 바가 제대로 담겨있는지 확인해보세요. 간결성을 위해 의미를 포기한건 아닌지 살펴보세요.


Unit Test는 처음에 시작하기가 매우 어렵습니다. 왜냐면 작성한 코드를 빨리 돌려 보고 싶고, 웹페이지를 만들어 보고 싶거든요. 그러나 일단 테스트를 진행하게 되면 한결 마음이 편해집니다. 여러 곳에서 문제가 발생하니까요. 전문 코더는 테스트 과정을 필수로 거칠 것입니다.

이제 마지막 테스트입니다. 어쩌면 제가 빼먹은 함수가 있어서 나중에 다시 테스트해야 할 수도 있겠네요. 테스트를 반복하니깐 테스트 코드 작성하는 시간도 많이 줄어듭니다!


테스트 하기전에 코드를 또 살짝 변경합니다. 함수 이름 finalizeWithdrawal에서 executeWithdrawal을 변경합니다. 다시 말씀드리지만, 변수 이름뿐 아니라 함수 이름도 의미를 충분히 전달해 주는 게 좋습니다. finalize라고 하면 왠지 더 이상 인출할 수 없다는 의미라서, author가 주기적으로 인출할 수 있기 때문에 의미상 execute가 더 정확하다고 생각했습니다. 그리고 앞서 변수 이름을 수정한 것도 이 함수에 반영합니다.

    /*
     * Execute the withdrawal by the author
     */
    function executeWithdrawal() public onlyAuthor {
        // half of contributors should approve the withdrawal
        require( approvers_count > ( votes_count/2 ) );
        // transfer the balance to the author
        author.transfer( address(this).balance );
    }


컨트랙트 코드를 변경하면 반드시 compile.js 스크립트를 실행해야 합니다. 다음과 같이 compile.js가 위치한 곳으로 이동하여 스크립트를 실행합니다.

$ cd ethereum
$ node compile.js


executeWithdrawal 함수 테스트

executeWithdrawal함수는 author가 approvers의 동의 하에 컨트랙트에 누적된 금액을 인출하여 자신의 계정에 전송합니다. 여기서는 contributors의 절반이 넘는 approvals을 받았을 때만 인출하도록 설정했습니다. 테스트할 것은 다음과 같습니다.

  • 테스트 ①: author만 함수를 실행할 수 있는가?
  • 테스트 ②: approvers_count가 절반이 넘을 때만 함수가 실행되는가?
  • 테스트 ③: 컨트랙트에서 author 계정으로 누적 금액이 전송되는가?

1. author가 아닌 계정으로 함수 호출 시도

먼저 author가 아닌 계정(accounts[2])으로 인출을 시도해 보겠습니다. 함수의 modifier로 인해 당연히 오류가 발생해야 합니다. 테스트 코드에서는 오류가 발생하면 테스트가 성공하는 방식으로 작성되었습니다. 다음과 같이 새롭게 테스트 블록을 만듭니다.

// test groups: executeWithdrawal tests
describe( 'DreamStory executeWithdrawal function tests', () => {
  //// test: try to execute the withdrawal using no author's account
  it( 'author test', async () => {
    // call executeWithdrawal using account[2] which is not the author
    try {
      await dream_story.methods.executeWithdrawal().send( {
        from: accounts[2],
        // gas limit to execute this function
        gas: 1000000
      });
      assert( false );
    }
    catch( error ) {
      // if error occurs, this assert will pass, which is intended
      assert( error );
      console.log( 'true' );
    }
  });
});

author가 아닌 accounts[2] 계정으로 executeWithdrawal 함수를 호출했을 때 try -catch 문에서 에러가 발생합니다. 이 함수는 상태변수의 값을 바꾸기 때문에 gas가 소모되므로 적절한 gas limit을 입력해야 합니다.

테스트는 assert( error )에 의해 pass. error object가 있기 때문에 assert 결과는 true가 됩니다. 테스트 결과는 생략하겠습니다.


2. 인출하기 위한 approvals 수와 balance transfer

다음 두 가지 테스트를 진행하겠습니다. 최종적으로 컨트랙트의 잔액을 인출하는 함수기 때문에 사전 작업이 많이 필요합니다.

  • 테스트 ①: approvals이 절반인 상태에서 인출 시도
  • 테스트 ②: author 계정의 balance 증가 확인

참고로 ①번의 테스트 블록(describe) 내에 다음 코드가 있어야 합니다. 테스트 블록 내에서 개별적으로 수행될 수 있는 테스트는 'it'으로 분리했습니다.

////// tests: not enough approvals
  //            contract's balance tranfer
  it( 'Check number of approvals and balance transfer', async () => {
    // get the intial balance of the author, which is accounts[0]
    let init_balance= await web3.eth.getBalance( accounts[0] );
    // convert to ether
    init_balance= web3.utils.fromWei( init_balance, 'ether' );
    // cast string to float
    init_balance= parseFloat( init_balance );

    // use the second account to contribute
    await dream_story.methods.contribute().send( {
      from: accounts[1],
      // amount to contribute. convert the ether into wei
      value: web3.utils.toWei( INIT_CONTRIBUTE_ETH, 'ether' ),
      // gas limit to execute this function
      gas: 1000000
    });

    // approve by the second account
    await dream_story.methods.approveWithdrawal().send( {
      from: accounts[1],
      // gas limit to execute this function
      gas: 1000000
    });

    // use the third account to contribute
    await dream_story.methods.contribute().send( {
      from: accounts[2],
      // amount to contribute. convert the ether into wei
      value: web3.utils.toWei( INIT_CONTRIBUTE_ETH, 'ether' ),
      // gas limit to execute this function
      gas: 1000000
    });
    // no approval by the third account

    // get the summary
    const summary= await dream_story.methods.getSummary().call();
    // print out the votes_count
    console.log( summary[1] );
    // print out the approvers_count
    console.log( summary[4] );

    //// test: not enough number of approvals
    // call executeWithdrawal using the author account
    try {
      await dream_story.methods.executeWithdrawal().send( {
        from: accounts[0],
        // gas limit to execute this function
        gas: 1000000
      });
      assert( false );
    }
    catch( error ) {
      // if error occurs, this assert will pass, which is intended
      assert( error );
      console.log( 'true' );
    }

    //// test: balance transfer
    // approve by the thirde account
    await dream_story.methods.approveWithdrawal().send( {
      from: accounts[2],
      // gas limit to execute this function
      gas: 1000000
    });
    // get the contract balance before withdrawal
    const contract_balance= await web3.eth.getBalance( dream_story.options.address );
    // execute the withdrawal by the author
    await dream_story.methods.executeWithdrawal().send( {
      from: accounts[0],
      // gas limit to execute this function
      gas: 1000000
    });
    // get the balance of the author, which is accounts[0]
    let balance= await web3.eth.getBalance( accounts[0] );
    // convert to ether
    balance= web3.utils.fromWei( balance, 'ether' );
    // cast string to float
    balance= parseFloat( balance );
    assert( balance > init_balance );
    // print out contract's balance before withdrawal
    console.log( web3.utils.fromWei( contract_balance, 'ether' ) );
    // print out initial balance
    console.log( init_balance );
    // print out the balance
    console.log( balance );
  });

accounts[1], accounts[2]로 각각 contribute 실행한 후 accounts[1]만 approveWithdrawal 실행합니다.

  • 테스트 ①: contributors 수는 2로, approvals 수는 1로, 절반을 넘는 동의를 얻지 못한 상태에서 함수 실행을 체크합니다.
  • 테스트 ②: accounts[2]도 approve한 후 author의 balance가 증가하는지 체크합니다.

테스트 결과는 다음과 같습니다.

첫 번째 2는 contributors 갯수며, 두 번째 1은 approvers 갯수입니다. 세 번째 true는 try-catch 문에서 approvers 수가 부족한 데 따른 error 발생입니다(의도한 것이 기 때문에 true).

네 번째 2는 balance transfer 전의 컨트랙트 잔액(ether)입니다. 다섯 번째 99.9... 는 컨트랙트 balance transfer 전의 author 잔액(ether)입니다.

여섯번째 101.8...는 컨트랙트 balance transfer 후의 author 잔액(ether)입니다.

author가 executeWithdrawal 함수를 실행하는데 약간의 gas가 소모되어 정확히 2 ether가 전송되지 않았습니다.



오늘의 실습: author가 여러 명 존재할텐데, 여러 authors는 어떻게 관리할 수 있을까요? 연관해서 DreamStory 생성자 함수는 테스트하지 않았습니다. 이건 누가 호출해야 할까요?



 [한빛미디어 블록체인 도서 보러가기]