--- tags: it 鐵人賽 30 天, Web 3, ethereum --- # 從以太坊白皮書理解 web 3 概念 - Day16 ## Learn Solidity - Day 8 - Testing Smart Contracts with Truffle 前面講述關於 Smart Contract 的撰寫 今天將會透過 [Lession 11 - Testing Smart Contracts with Truffle](https://cryptozombies.io/en/lesson/11) 來學關於如何測試 Smart Contract 因為一旦發佈到鏈上, bug 將會永遠存在鏈上 所以最好的方式就是在上鏈之前就作法測試 這章節將使用 Truffle 這個測試工具 並且使用本地節點 Gancache 在本機架設 Ethereum 節點 還有透過 Chai 撰寫測試 ## 環境設定 假設以前幾章節的 Project ZombieContract 其在 Truffle 的架構下 專案檔案架構應該如下 ```javascript= ├── build ├── contracts ├── Migrations.json ├── CryptoZombies.json ├── erc721.json ├── ownable.json ├── safemath.json ├── zombieattack.json ├── zombiefactory.json ├── zombiefeeding.json ├── zombiehelper.json ├── zombieownership.json ├── contracts ├── Migrations.sol ├── CryptoZombies.sol ├── erc721.sol ├── ownable.sol ├── safemath.sol ├── zombieattack.sol ├── zombiefactory.sol ├── zombiefeeding.sol ├── zombiehelper.sol ├── zombieownership.sol ├── migrations └── test . package-lock.json . truffle-config.js . truffle.js ``` ### 實作測試檔案 建立一個 CryptoZombies.js 在 test 資料下 ```shell= touch test/CryptoZombies.js ``` ## 建立 artifacts 每次當編譯一個 Smart Contract, solidity 編譯器會產生一個 JSON 檔案。這個 JSON 檔案包含 Contract abi 會存放在 build/contracts 資料夾下 當 Contract 更新執行 migration 時, Truffle 會自動更新 build/contracts 資料夾內的內容 而測試第一件事情就是載入 build/contracts 內建立好的 artifacts 來做測試。 語法如下: ```javascript= const MyAwesomeContract = artifacts.require("MyAwesomeContract"); ``` 這個 function 會讀出 contract abi 。讓 javascript 方便可以透過該 interface 與 Contract 做互動 ## contract() function 在背後, Truffle 有使用 Mocha 這個測試的框架為了簡化測試。 以下會是接下會去實作的部份: * group test: 透過呼叫 contract() function 來做群組測試 。 contract function 繼承了 Mocha 的 describe 功能,加入了一組 account 測試並且再測試完會自動清除。 contract() 需要兩個參數:第一個是 string 用來描素要做的測項,第二個是 callback 是真正要去執行測試的部份 * 執行: 透過 it() function 可以真正去執行測試。 it() function 需要兩個參數,第一個是字串用來描述測試主題,第二個是 callback 會真正實作測試本身。 範例如下: ```javascript= contract("MyAwesomeContract", (accounts) => { it("should be able to receive Ethers", () => { }) }) ``` ### 撰寫測試 1. 宣告 const CryptoZombies 變數,設定其值為 aritifacts.require 要測試的 contract 2. 宣告 contract() 並寫入測試大項內容 3. 宣告 it() 並寫入測試大項內容 ```javascript= const CryptoZombies = artifacts.require("CryptoZombies"); contract("CryptoZombies", (accounts) => { it("should be able to create a new zombie", () => { }) }) ``` ## 第一個測試 -- 建立一個新的 Zombie 在發佈到 Ethereum 鏈上前,可以先在自己架設的 Ganache 節點測試一下。 每次開啟 Gancache 節點時,Gancache 會自動建立 10 個測試帳號,每個初始化 100 Ether。 因為 Truffle 與 Gancache 整合的很好,所以可以直接透過 accounts 這個陣列直接取得帳號的 address 語法如下 ```javascript= let [alice, bob] = accounts; ``` ### 回顧建立 Zombie Contract 的部份 ```solidity= function createRandomZombie(string _name) public { require(ownerZombieCount[msg.sender] == 0); uint randDna = _generateRandomDna(_name); randDna = randDna - randDna % 100; _createZombie(_name, randDna); } ``` ### 撰寫測試 1. 在 contract(), 宣告兩個變數 alice 與 bob 從 accounts 讀取出值。 2. 添加 async 宣告到 it() 的第二個 callback function 前面,為了比較好撰寫非同步邏輯 ```javascript= const CryptoZombies = artifacts.require("CryptoZombies"); contract("CryptoZombies", (accounts) => { //1. initialize `alice` and `bob` let [alice, bob] = accounts; it("should be able to create a new zombie", async () => { //2 & 3. Replace the first parameter and make the callback async }) }) ``` ## 撰寫 Create New Zombie 測試邏輯 測試邏輯可以分成3個步驟 1. 設定:這步驟是定一個測試前的狀態,初始化一些設定還有參數 2. 執行:根據設定的參數實際上去執行撰寫的邏輯。通常我們會把測試範圍儘量縮小。 3. 檢驗:檢驗執行步驟所得到的結果 舉個例子: 假設要測試 MyAwesomeContract 的建立新 createRandomZombie 功能 因為還需要傳入 zombie 名稱來呼叫 設定的部份會如下: ```javascript= const contractInstance = await MyAwesomeContract.new(); // const zombieNames = ["Zombie #1", "Zombie #2"]; ``` 執行的部份則會如下 ```javascript= contractInstance.createRandomZombie(zombieNames[0]); ``` ### 撰寫測試設定部份 1. 宣告 const contractInstance = await CryptoZombies.new(); ```javascript= const CryptoZombies = artifacts.require("CryptoZombies"); const zombieNames = ["Zombie 1", "Zombie 2"]; contract("CryptoZombies", (accounts) => { let [alice, bob] = accounts; it("should be able to create a new zombie", async () => { // start here const constractInstance = await CryptoZombies.new(); }) }) ``` ## 撰寫 Creating a New Zombie 測試執行的部份 ## 執行 createRandomZombie 面臨的第一個問題是? 如何設定 ZombieOwner 透過 abi ,可以透過帶入 from 參數設定 owner 如下: ```javascript= const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); ``` ## Logs and Events 第二個問題是 result 的格式是什麼? 在 Truffle 從 artifacts.require 初始化 abi 後, Truffle 會自動透過 Smart Contract 產生 log 。 舉例來說: result.logs[0].args.name 就可以取的執行後的 zombie 名稱。 此外基上 , result 還有其他重要的結構如下 * result.tx: 產生的 transaction hash * result.receipt: 一個物件包含 transaction 的 receipt 物件。 如果 result.receipt.status 是 true , 代表交易成功,否則就是失敗。 ### 驗證 驗證的部份可以透過 assertion function 如 equal 或是 deepEqual 來做。但這邊只會查單純的值,所以只會用 assert.equal() ### 實作步驟 1. 宣告 const result 並且設定值為執行contractInstance.createRandomZombie 的結果 2. 加入以下檢驗 assert.equal(result.receipt.status, true); 3. 加入以下檢驗 assert.equal(result.logs[0].args.name, zombieNames[0]); ```javascript= const CryptoZombies = artifacts.require("CryptoZombies"); const zombieNames = ["Zombie 1", "Zombie 2"]; contract("CryptoZombies", (accounts) => { let [alice, bob] = accounts; it("should be able to create a new zombie", async () => { const contractInstance = await CryptoZombies.new(); // start here const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); assert.equal(result.receipt.status, true); assert.equal(result.logs[0].args.name, zombieNames[0]); }) }) ``` ## 針對 createZombie 功能檢核做測試 之前在 createZombie 功能有設定一個檢核 zombie 數量的限制 如下: ```solidity= require(ownerZombieCount[msg.sender] == 0) ``` 用來限制只能產生一個 zombie 然而,我們的測試會跑超過一次的 createZombie 為了能夠順利執行 我們需要使用 beforeEach 這個 Mocha 內定的 Hook 來確保每次的 contractInstance 都是全新的 如下: ```javascript= beforeEach(async () => { const contractInstance = await CryptoZombies.new(); } ``` 這樣一來, Truffle 就會確保每次測試值執行前都重新產生新的 contract instance ### 實作測試 1. 宣告 let contractInstance; 2. 加入 beforeEach 設定 3. 把產生新的 contractInstance 邏輯放入 beforeEach 4. 加入新的測試項目 it("should not allow two zombies") ```javascript= const CryptoZombies = artifacts.require("CryptoZombies"); const zombieNames = ["Zombie 1", "Zombie 2"]; contract("CryptoZombies", (accounts) => { let [alice, bob] = accounts; // start here let contractInstance; beforeEach(async () => { contractInstance = await CryptoZombies.new(); }); it("should be able to create a new zombie", async () => { const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); assert.equal(result.receipt.status, true); assert.equal(result.logs[0].args.name,zombieNames[0]); }) //define the new it() function it("should not allow two zombies", async () => { }) }) ``` ### 關於 contract.new 每次做測試時, Truffle 會透過 contract.new 來產生一個新的 Contract 對測試來說這樣可以產生的狀態乾淨的 Contract 避免被狀態影響 然而這樣每次測試一次就出現一個新的測試 Contract 會造成鏈上過多無用的 Contract 為了避免這種狀框發生,會需要每個 Contract 自行定義一個 selfdestruct 來銷毀 Contract 當不需要使用時 selfdestruct 大致上流程如下: 1. 會需要在 CryptoZombies Contract 加入以下功能 ```solidity= function kill() public onlyOwner { selfdestruct(owner()); } ``` 2. 下一步是在 afterEach() 加入 以下邏輯 ```javascript= afterEach(async () => { await contractInstance.kill(); }) ``` 3. Truffle 會在每個測試結束之後呼叫 constractInstance.kill(); 這樣就可以確保每個 Contract 在測試後被銷毀 ## 實作檢測同一帳號不能使用 createRandomZombie 產生兩個 Zombie 測試邏輯如下 1. alice 先用 zombieNames[0] 呼叫 createRandomZombie 2. alice 再用 zombieNames[1] 呼叫 createRandomZombie 3. 這時會預期 Contract 拋出 error 4. 需要使用 try catch 語法來抓取這個 error 語法如下: ```javascript= try { //try to create the second zombie await contractInstance.createRandomZombie(zombieNames[1], {from: alice}); assert(true); } catch (err) { return; } assert(false, "The contract did not throw."); ``` 為了讓測試邏輯儘量簡化,所以會把上面的邏輯放到 helpers/util.js 然後在測試部份在引入 這時測試語法就可以簡化成以下 ```javascript= const utils = require("./helpers/utils"); await utils.shouldThrow(MyAwesomeContractInstance.myAwesomeFunction()); ``` ### 實作步驟 1. 先讓 alice 用 zombieNames[0] 呼叫 createRandomZombie 2. 再讓 alice 用 zombieNames[1] 呼叫 createRandomZombie,並且透過 shouldTrow 語法來檢測結果 ```javascript= const CryptoZombies = artifacts.require("CryptoZombies"); const utils = require("./helpers/utils"); const zombieNames = ["Zombie 1", "Zombie 2"]; contract("CryptoZombies", (accounts) => { let [alice, bob] = accounts; let contractInstance; beforeEach(async () => { contractInstance = await CryptoZombies.new(); }); it("should be able to create a new zombie", async () => { const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); assert.equal(result.receipt.status, true); assert.equal(result.logs[0].args.name,zombieNames[0]); }) it("should not allow two zombies", async () => { // start here await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); await utils.shouldThrow(contractInstance.createRandomZombie(zombieNames[1], {from: alice})); }) }) ``` ## 檢測 Zombie Transfer 一共有兩種情境要檢測 第1種情境: Alice 使用 transferFrom 把 zombie 轉給 Bob 察看 transferFrom 如下: ```solidity= function transferFrom(address _from, address _to, uint256 _tokenId) external payable; ``` 所以需要把 alice 代入 _from , bob 代入 _to, 把 zombie id 帶入 _tokenId 然後檢測執行結果 第2種情境: Alice 先使用 approve 登計要轉讓 zombieId 給 Bob , Bob 或者 Alice 再呼叫 transferFrom 傳送 zombieId 察看使用的兩個 function: ```solidity= function approve(address _approved, uint256 _tokenId) external payable; function transferFrom(address _from, address _to, uint256 _tokenId) external payable; ``` ### Context function 為了讓測試具有結構性, Truffle 提供一個 context function 可以把測試分組,語法如下: ```javascript= context("with the single-step transfer scenario", async () => { it("should transfer a zombie", async () => { // TODO: Test the single-step transfer scenario. }) }) context("with the two-step transfer scenario", async () => { it("should approve and then transfer a zombie when the approved address calls transferFrom", async () => { // TODO: Test the two-step scenario. The approved address calls transferFrom }) it("should approve and then transfer a zombie when the owner calls transferFrom", async () => { // TODO: Test the two-step scenario. The owner calls transferFrom }) }) ``` 但如果這樣寫直接去跑 truffle test 會發現結果如下: ```javascript Contract: CryptoZombies ✓ should be able to create a new zombie (100ms) ✓ should not allow two zombies (251ms) with the single-step transfer scenario ✓ should transfer a zombie with the two-step transfer scenario ✓ should approve and then transfer a zombie when the owner calls transferFrom ✓ should approve and then transfer a zombie when the approved address calls transferFrom 5 passing (2s) ``` 還沒寫測試單卻都通過了! 問題在於沒做寫 assertion 但為了在還沒寫之前先跳過 可以先再 context 前加入一個 x,也就是 xcontext 代表要跳過的檢測 加入 xcontext 之後,執行 truffle test 結果會如下 ```javascript= Contract: CryptoZombies ✓ should be able to create a new zombie (199ms) ✓ should not allow two zombies (175ms) with the single-step transfer scenario - should transfer a zombie with the two-step transfer scenario - should approve and then transfer a zombie when the owner calls transferFrom - should approve and then transfer a zombie when the approved address calls transferFrom 2 passing (827ms) 3 pending ``` - 出現在測項之前是因為使用了 xcontext 讓該測向跳過 ### 實作內容 ```javascript= const CryptoZombies = artifacts.require("CryptoZombies"); const utils = require("./helpers/utils"); const zombieNames = ["Zombie 1", "Zombie 2"]; contract("CryptoZombies", (accounts) => { let [alice, bob] = accounts; let contractInstance; beforeEach(async () => { contractInstance = await CryptoZombies.new(); }); it("should be able to create a new zombie", async () => { const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); assert.equal(result.receipt.status, true); assert.equal(result.logs[0].args.name,zombieNames[0]); }) it("should not allow two zombies", async () => { await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); await utils.shouldThrow(contractInstance.createRandomZombie(zombieNames[1], {from: alice})); }) // start here xcontext("with the single-step transfer scenario", async () => { it("should transfer a zombie", async () => { // TODO: Test the single-step transfer scenario. }) }) xcontext("with the two-step transfer scenario", async () => { it("should approve and then transfer a zombie when the approved address calls transferFrom", async () => { // TODO: Test the two-step scenario. The approved address calls transferFrom }) it("should approve and then transfer a zombie when the owner calls transferFrom", async () => { // TODO: Test the two-step scenario. The owner calls transferFrom }) }) }) ``` ## 實作 single-step transfer 測試 1. alice 先使用 zombieNames[0] 呼叫 createRandomZombie 2. 宣告 const zombieId , 設定值為剛剛產生出來的 zombie 的 id 3. alice 呼叫 transferFrom 轉移 zombieId 給 bob 4. 宣告 const newOwner 設定值為 ownerOf zombieId 5. 最後檢查 bob 是否擁有這個 zombieId 6. 把 xcontext 改回 context ```javascript= const CryptoZombies = artifacts.require("CryptoZombies"); const utils = require("./helpers/utils"); const zombieNames = ["Zombie 1", "Zombie 2"]; contract("CryptoZombies", (accounts) => { let [alice, bob] = accounts; let contractInstance; beforeEach(async () => { contractInstance = await CryptoZombies.new(); }); it("should be able to create a new zombie", async () => { const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); assert.equal(result.receipt.status, true); assert.equal(result.logs[0].args.name,zombieNames[0]); }) it("should not allow two zombies", async () => { await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); await utils.shouldThrow(contractInstance.createRandomZombie(zombieNames[1], {from: alice})); }) context("with the single-step transfer scenario", async () => { it("should transfer a zombie", async () => { // start here. const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); const zombieId = result.logs[0].args.zombieId.toNumber(); await contractInstance.transferFrom(alice, bob, zombieId, {from: alice}); const newOwner = await contractInstance.ownerOf(zombieId); assert.equal(newOwner, bob); }) }) xcontext("with the two-step transfer scenario", async () => { it("should approve and then transfer a zombie when the approved address calls transferFrom", async () => { // TODO: Test the two-step scenario. The approved address calls transferFrom }) it("should approve and then transfer a zombie when the owner calls transferFrom", async () => { // TODO: Test the two-step scenario. The owner calls transferFrom }) }) }) ``` ## 實作 two-step transfer 檢測 1. alice 先使用 zombieNames[0] 呼叫 createRandomZombie 2. alice 使用 zombieId, bob 呼叫 approve 3. bob 呼叫 transferFrom 從 alice 接收 zombieId 4. 檢查 bob 是否是 zombieId 的 owner 5. 把 xcontext 更新為 context ```javascript= const CryptoZombies = artifacts.require("CryptoZombies"); const utils = require("./helpers/utils"); const zombieNames = ["Zombie 1", "Zombie 2"]; contract("CryptoZombies", (accounts) => { let [alice, bob] = accounts; let contractInstance; beforeEach(async () => { contractInstance = await CryptoZombies.new(); }); it("should be able to create a new zombie", async () => { const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); assert.equal(result.receipt.status, true); assert.equal(result.logs[0].args.name,zombieNames[0]); }) it("should not allow two zombies", async () => { await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); await utils.shouldThrow(contractInstance.createRandomZombie(zombieNames[1], {from: alice})); }) context("with the single-step transfer scenario", async () => { it("should transfer a zombie", async () => { const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); const zombieId = result.logs[0].args.zombieId.toNumber(); await contractInstance.transferFrom(alice, bob, zombieId, {from: alice}); const newOwner = await contractInstance.ownerOf(zombieId); assert.equal(newOwner, bob); }) }) context("with the two-step transfer scenario", async () => { it("should approve and then transfer a zombie when the approved address calls transferFrom", async () => { const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); const zombieId = result.logs[0].args.zombieId.toNumber(); // start here await contractInstance.approve(bob, zombieId, {from: alice}); await contractInstance.transferFrom(alice, bob, zombieId, {from: bob}); const newOwner = await contractInstance.ownerOf(zombieId); assert.equal(newOwner, bob); }) xit("should approve and then transfer a zombie when the owner calls transferFrom", async () => { }) }) }) ``` ## 實作 two-step transferFrom 第2種情境測試 1. alice 先 approve 2. alice 呼叫 transferFrom ```javascript= const CryptoZombies = artifacts.require("CryptoZombies"); const utils = require("./helpers/utils"); const zombieNames = ["Zombie 1", "Zombie 2"]; contract("CryptoZombies", (accounts) => { let [alice, bob] = accounts; let contractInstance; beforeEach(async () => { contractInstance = await CryptoZombies.new(); }); it("should be able to create a new zombie", async () => { const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); assert.equal(result.receipt.status, true); assert.equal(result.logs[0].args.name,zombieNames[0]); }) it("should not allow two zombies", async () => { await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); await utils.shouldThrow(contractInstance.createRandomZombie(zombieNames[1], {from: alice})); }) context("with the single-step transfer scenario", async () => { it("should transfer a zombie", async () => { const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); const zombieId = result.logs[0].args.zombieId.toNumber(); await contractInstance.transferFrom(alice, bob, zombieId, {from: alice}); const newOwner = await contractInstance.ownerOf(zombieId); assert.equal(newOwner, bob); }) }) context("with the two-step transfer scenario", async () => { it("should approve and then transfer a zombie when the approved address calls transferFrom", async () => { const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); const zombieId = result.logs[0].args.zombieId.toNumber(); await contractInstance.approve(bob, zombieId, {from: alice}); await contractInstance.transferFrom(alice, bob, zombieId, {from: bob}); const newOwner = await contractInstance.ownerOf(zombieId); assert.equal(newOwner,bob); }) it("should approve and then transfer a zombie when the owner calls transferFrom", async () => { // TODO: start const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); const zombieId = result.logs[0].args.zombieId.toNumber(); await contractInstance.approve(bob, zombieId, {from: alice}); await contractInstance.transferFrom(alice, bob, zombieId, {from: alice}); const newOwner = await contractInstance.ownerOf(zombieId); assert.equal(newOwner,bob); }) }) }) ``` ## 檢測 Zombie Attack 測試情境如下 1. 先建立了二個 zombie:一個 owner 是 alice ,另一個是 bob 2. 讓 bob zombie attack alice zombie 3. 最後檢查 result.recepit.status 是不是 true 假設照著上面的邏輯去實作測試 然後執行 truffle test 會發現以下的結果 ```javascript= Contract: CryptoZombies ✓ should be able to create a new zombie (102ms) ✓ should not allow two zombies (321ms) ✓ should return the correct owner (333ms) 1) zombies should be able to attack another zombie with the single-step transfer scenario ✓ should transfer a zombie (307ms) with the two-step transfer scenario ✓ should approve and then transfer a zombie when the approved address calls transferFrom (357ms) 5 passing (7s) 1 failing 1) Contract: CryptoZombies zombies should be able to attack another zombie: Error: Returned error: VM Exception while processing transaction: revert ``` 看起來是失敗 那原因是什麼呢? 首先察看 createZombie 邏輯 ```solidity= function createRandomZombie(string _name) public { require(ownerZombieCount[msg.sender] == 0); uint randDna = _generateRandomDna(_name); randDna = randDna - randDna % 100; _createZombie(_name, randDna); } ``` 然後再察看 _createZombie ```solidity= function _createZombie(string _name, uint _dna) internal { uint id = zombies.push(Zombie(_name, _dna, 1, uint32(now + cooldownTime), 0, 0)) - 1; zombieToOwner[id] = msg.sender; ownerZombieCount[msg.sender] = ownerZombieCount[msg.sender].add(1); emit NewZombie(id, _name, _dna); } ``` 注意到在 _createZombie 邏輯裡使用了 cooldownTime 來減緩產生 zombie 的產生時間 而這個時間設定為一天 因此,直接測試會是失敗的 所以該怎麼處理這種狀況呢? ## Time Travelling 在這種狀況下,為了能夠處理這類時間限制的問題 Ganache 節點提供了以下方法來處理時間測試 * evm_increaseTime: 把下個區塊時間增加 * evm_mine: 挖出一個新 block 以下示範如何使用 1. 每次新的 block 被挖出來,挖礦節點會把 timestamp 附加到 block 上。 2. 使用了 evm_increaseTime 把區塊時間增加了,但因為過去的區塊已寫入無法更改 3. 所以只能透過 evm_mine 產生新的區塊把剛剛 evm_increaseTime 寫入做更新 更新語法如下: ```javascript= await web3.currentProvider.sendAsync({ jsonrpc: "2.0", method: "evm_increaseTime", params: [86400], // there are 86400 seconds in a day id: new Date().getTime() }, () => { }); web3.currentProvider.send({ jsonrpc: '2.0', method: 'evm_mine', params: [], id: new Date().getTime() }); ``` 以上的程式碼就可以讓 evm 時間條快 1 天 透過把這段邏輯封裝到 helpers/time.js 就可以透過 time.increaseTime(86400) 語法來模擬時間過了一天 但這樣可讀性還是不夠好 所以會透過 await time.increase(time.duration.days(1)) 這樣的語法來呼叫 ### 實作 ```javascript= const CryptoZombies = artifacts.require("CryptoZombies"); const utils = require("./helpers/utils"); const time = require("./helpers/time"); const zombieNames = ["Zombie 1", "Zombie 2"]; contract("CryptoZombies", (accounts) => { let [alice, bob] = accounts; let contractInstance; beforeEach(async () => { contractInstance = await CryptoZombies.new(); }); it("should be able to create a new zombie", async () => { const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); assert.equal(result.receipt.status, true); assert.equal(result.logs[0].args.name,zombieNames[0]); }) it("should not allow two zombies", async () => { await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); await utils.shouldThrow(contractInstance.createRandomZombie(zombieNames[1], {from: alice})); }) context("with the single-step transfer scenario", async () => { it("should transfer a zombie", async () => { const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); const zombieId = result.logs[0].args.zombieId.toNumber(); await contractInstance.transferFrom(alice, bob, zombieId, {from: alice}); const newOwner = await contractInstance.ownerOf(zombieId); assert.equal(newOwner, bob); }) }) context("with the two-step transfer scenario", async () => { it("should approve and then transfer a zombie when the approved address calls transferFrom", async () => { const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); const zombieId = result.logs[0].args.zombieId.toNumber(); await contractInstance.approve(bob, zombieId, {from: alice}); await contractInstance.transferFrom(alice, bob, zombieId, {from: bob}); const newOwner = await contractInstance.ownerOf(zombieId); assert.equal(newOwner,bob); }) it("should approve and then transfer a zombie when the owner calls transferFrom", async () => { const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); const zombieId = result.logs[0].args.zombieId.toNumber(); await contractInstance.approve(bob, zombieId, {from: alice}); await contractInstance.transferFrom(alice, bob, zombieId, {from: alice}); const newOwner = await contractInstance.ownerOf(zombieId); assert.equal(newOwner,bob); }) }) it("zombies should be able to attack another zombie", async () => { let result; result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); const firstZombieId = result.logs[0].args.zombieId.toNumber(); result = await contractInstance.createRandomZombie(zombieNames[1], {from: bob}); const secondZombieId = result.logs[0].args.zombieId.toNumber(); //TODO: increase the time await time.increase(time.duration.days(1)); await contractInstance.attack(firstZombieId, secondZombieId, {from: alice}); assert.equal(result.receipt.status, true); }) }) ``` ## Chai 語法 assert function 雖然能夠幫忙做到檢測 但其問題是可讀性不夠好 所以可以使用 Chai 這類輔助 library 來達到這件事情 ### Chai Assertion Library Chai 是很強大的 assertion 函式庫。 主要有三類語法 * expect: 讓使用者可以使用鏈狀判斷式 如下 ```javascript= let lessonTitle = "Testing Smart Contracts with Truffle"; expect(lessonTitle).to.be.a("string"); ``` * should: 類似於 expect ,但 chain 會以 should 當作開始 如下 ```javascript= let lessonTitle = "Testing Smart Contracts with Truffle"; lessonTitle.should.be.a("string"); ``` * assert: 提供語法支援 nodejs 與 browser 測試寫法 如下 ```javascript= let lessonTitle = "Testing Smart Contracts with Truffle"; assert.typeOf(lessonTitle, "string"); ``` ### 以下將使用 expect 來做測試 引用語法如下 ```javascript= var expect = require('chai').expect; ``` #### to.equal() ```javascript= let zombieName = 'My Awesome Zombie'; expect(zombieName).to.equal('My Awesome Zombie'); ``` ### 實作 1. 引入 expect 2. 把 assert.equal 取代為 expect ```javascript= const CryptoZombies = artifacts.require("CryptoZombies"); const utils = require("./helpers/utils"); const time = require("./helpers/time"); //TODO: import expect into our project var expect = require('chai').expect; const zombieNames = ["Zombie 1", "Zombie 2"]; contract("CryptoZombies", (accounts) => { let [alice, bob] = accounts; let contractInstance; beforeEach(async () => { contractInstance = await CryptoZombies.new(); }); it("should be able to create a new zombie", async () => { const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); //TODO: replace with expect expect(result.receipt.status).to.equal(true); expect(result.logs[0].args.name).to.equal(zombieNames[0]); }) it("should not allow two zombies", async () => { await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); await utils.shouldThrow(contractInstance.createRandomZombie(zombieNames[1], {from: alice})); }) context("with the single-step transfer scenario", async () => { it("should transfer a zombie", async () => { const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); const zombieId = result.logs[0].args.zombieId.toNumber(); await contractInstance.transferFrom(alice, bob, zombieId, {from: alice}); const newOwner = await contractInstance.ownerOf(zombieId); //TODO: replace with expect expect(newOwner).to.equal(bob); }) }) context("with the two-step transfer scenario", async () => { it("should approve and then transfer a zombie when the approved address calls transferFrom", async () => { const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); const zombieId = result.logs[0].args.zombieId.toNumber(); await contractInstance.approve(bob, zombieId, {from: alice}); await contractInstance.transferFrom(alice, bob, zombieId, {from: bob}); const newOwner = await contractInstance.ownerOf(zombieId); //TODO: replace with expect expect(newOwner).to.equal(bob); }) it("should approve and then transfer a zombie when the owner calls transferFrom", async () => { const result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); const zombieId = result.logs[0].args.zombieId.toNumber(); await contractInstance.approve(bob, zombieId, {from: alice}); await contractInstance.transferFrom(alice, bob, zombieId, {from: alice}); const newOwner = await contractInstance.ownerOf(zombieId); //TODO: replace with expect expect(newOwner).to.equal(bob); }) }) it("zombies should be able to attack another zombie", async () => { let result; result = await contractInstance.createRandomZombie(zombieNames[0], {from: alice}); const firstZombieId = result.logs[0].args.zombieId.toNumber(); result = await contractInstance.createRandomZombie(zombieNames[1], {from: bob}); const secondZombieId = result.logs[0].args.zombieId.toNumber(); await time.increase(time.duration.days(1)); await contractInstance.attack(firstZombieId, secondZombieId, {from: alice}); //TODO: replace with expect expect(result.receipt.status).to.equal(true); }) }) ``` ## 透過 loom 來做測試 Loom 是一個測試鏈 可以使用比以太鏈快與使用不用 gas 的交易來測試 Contract ### 設定 Truffle 使用 Loom 必須修改連線網路的部份 ```javascript= loom_testnet: { provider: function() { const privateKey = 'YOUR_PRIVATE_KEY'; const chainId = 'extdev-plasma-us1'; const writeUrl = 'wss://extdev-basechain-us1.dappchains.com/websocket'; const readUrl = 'wss://extdev-basechain-us1.dappchains.com/queryws'; return new LoomTruffleProvider(chainId, writeUrl, readUrl, privateKey); }, network_id: 'extdev' } ``` ### accounts array 為了讓 Truffle 可以跟 Loom 節點溝通,需要修改預設 HDWalletProvider 為 Truffle Provider 然後必須要把產生幾個測試帳號都設定在 Provider 內 所以要修改 ```javascript= return new LoomTruffleProvider(chainId, writeUrl, readUrl, privateKey) ``` 為 ```javascript= const loomTruffleProvider = new LoomTruffleProvider(chainId, writeUrl, readUrl, privateKey); loomTruffleProvider.createExtraAccountsFromMnemonic(mnemonic, 10); return loomTruffleProvider; ``` ### 實作 1. 修改回傳 LoomTruffleProvider ```javascript= const HDWalletProvider = require("truffle-hdwallet-provider"); const LoomTruffleProvider = require('loom-truffle-provider'); const mnemonic = "YOUR MNEMONIC HERE"; module.exports = { // Object with configuration for each network networks: { //development development: { host: "127.0.0.1", port: 7545, network_id: "*", gas: 9500000 }, // Configuration for Ethereum Mainnet mainnet: { provider: function() { return new HDWalletProvider(mnemonic, "https://mainnet.infura.io/v3/<YOUR_INFURA_API_KEY>") }, network_id: "1" // Match any network id }, // Configuration for Rinkeby Metwork rinkeby: { provider: function() { // Setting the provider with the Infura Rinkeby address and Token return new HDWalletProvider(mnemonic, "https://rinkeby.infura.io/v3/<YOUR_INFURA_API_KEY>") }, network_id: 4 }, // Configuration for Loom Testnet loom_testnet: { provider: function() { const privateKey = 'YOUR_PRIVATE_KEY'; const chainId = 'extdev-plasma-us1'; const writeUrl = 'wss://extdev-basechain-us1.dappchains.com/websocket'; const readUrl = 'wss://extdev-basechain-us1.dappchains.com/queryws'; // TODO: Replace the line below const loomTruffleProvider = new LoomTruffleProvider(chainId, writeUrl, readUrl, privateKey); loomTruffleProvider.createExtraAccountsFromMnemonic(mnemonic, 10); return loomTruffleProvider; }, network_id: '9545242630824' } }, compilers: { solc: { version: "0.4.25" } } }; ```