Snapshot을 활용해 Hardhat Test 속도 개선하기

JiJay
9 min readDec 31, 2022

개요

Hardhat은 블록체인에서 돌아가는 DApp 개발시 내장된 기능이 다양하고 디버깅이 편리하며 테스팅이 쉬워 많이 사용하는 개발 툴이다.

필자 또한 EVM 계열의 블록체인 위의 DApp 컨트랙 개발, 테스트, 배포시 Hardhat을 사용하는데 이번 글에서는 Hardhat으로 테스트 진행시 Snapshot 기능을 활용해 속도를 개선한 경험을 공유하고자 한다.

기존 테스트 방식과 문제점

먼저, 기존에 Hardhat을 통해 어떻게 테스트를 진행했는지 알아보자.

Hardhat을 사용한다면 테스트 방식은 대표적인 테스트 프레임워크인 Mocha와 사용 방식이 동일하다.

즉, Mocha에서 사용했던 문법인 describe, it과 훅 모두 사용 가능하다.

컨트랙을 테스트하기 위해서는 먼저, 컨트랙을 가상이라도 배포해야한다.

여기서 가상의 뜻은 실제 노드들로 운영되고 있는 메인넷이나 테스트넷이 아닌 Hardhat에서 제공해주는 hardhat 네트워크에 배포한다는 뜻으로 테스트동안 로컬 컴퓨터에 가상으로 배포한다고 생각하면 이해하기 쉽다.

즉, 테스트시마다 컨트랙을 배포하는 작업이 필요하다.

let contract;

describe("ERC20 컨트랙 테스트", () => {
beforeEach(async () => {
// hardhat 네트워크에 배포 후 contract 변수에 배포한 컨트랙 인스턴스 주입
await deployAndSet();
})
it("전송 테스트", () => {
...테스트
})
it("잔고 테스트", () => {
...테스트
})
})

따라서, beforeEach 훅에 컨트랙을 배포하고 컨트랙을 저장하는 전역 변수을 세팅해주는 작업을 넣어준다.

코드에서는 해당 작업을 deployAndSet 함수가 실행한다.

그런 다음, it 내부에서 테스트시 배포된 컨트랙을 가지고 테스트를 진행한다.

테스트 케이스마다 컨트랙을 새로 배포하기 때문에 각 테스트 케이스별로 다른 컨트랙으로 테스트를 진행하며 서로 영향을 주고 받지 않는다.

즉, 전송 테스트에서 어떤 테스트를 진행하더라도 잔고 테스트는 영향을 받지 않는다.

각 테스트 케이스마다 컨트랙을 배포하는 방법은 테스트 케이스의 격리화는 잘 되지만 테스트 케이스마다 컨트랙을 배포하기 때문에 시간이 오래 걸린다는 문제점이 있다.

실제로 테스트를 돌려보면 실제 테스트 로직은 금방 끝나는데 배포에 시간이 걸려 테스트의 전체 시간이 오래 걸리는 경우가 많다.

Snapshot을 활용한 개선 방안

위 방법의 문제점은 결국 시간이다.

테스트 시간이 오래 걸리는 문제는 사소해보일 수 있지만 CI 프로세스에 테스트가 들어가게되면 개발 생산성이 떨어지게 된다.

테스트 케이스마다 배포를 진행해 시간이 오래 걸리는 문제는 Snapshot을 활용해 개선할 수 있다.

Hardhat에서 제공하는 JSON-RPC 메소드들이 있는데 그 중 evm_snapshotevm_revert 가 있다.

evm_snapshot

  • 현재 블록의 상태들을 저장한다.
  • 해당 메소드를 호출하면 snapshot id를 반환하는데 나중에 id를 사용해 revert할 수 있다.

evm_revert

  • snapshot id를 인자로 넘기며 snapshot id에 해당하는 상태로 돌아간다.

쉽게 생각하면 evm_snapshot 메소드로 snapshot id를 저장한 후 여러 트랜잭션을 실행해 상태를 바꾸더라도 나중에 해당 snapshot id와 evm_revert메소드를 사용해 원래 상태로 돌아갈 수 있다는 뜻이다.

이쯤 되면 감이 잡힐 것이다.

시간이 오래 걸리더라도 테스트 케이스마다 컨트랙을 배포한 이유가 무엇인가?

바로, 테스트 케이스를 격리하여 서로 영향을 주고 받지 않게 하기 위해서다.

컨트랙 테스트에서 영향을 주고 받지 않는 다는 것은 특정 테스트 케이스에서 상태를 변경했을 때 다른 테스트 케이스에는 반영되지 않아야한다는 뜻이다.

1번 테스트 케이스에서 ERC20의 Transfer 트랜잭션을 실행했더라도 2번 테스트 케이스에서는 Transfer가 일어나지 않은 상태여야한다는 뜻이다.

기존 방법은 이를 위해 테스트 케이스마다 컨트랙을 배포해 각 테스트 케이스마다 새로운 컨트랙을 사용하게 만들었지만 Snapshot을 사용한다면 배포를 한번만 해서 하나의 컨트랙을 사용하더라도 테스트 케이스마다 격리시킬 수 있다.

import { network } from "hardhat";

let contract;
let snapshotId;

describe("ERC20 컨트랙 테스트", () => {

before(async () => {
// hardhat 네트워크에 배포 후 contract 변수에 배포한 컨트랙 인스턴스 주입
await deployAndSet();

...테스트 케이스에 공통된 로직 작성
})

beforeEach(async () => {
snapshotId = await network.provider.send('evm_snapshot');
})

afterEach(async () => {
await network.provider.send("evm_revert", [snapshotId]);
})

it("전송 테스트", () => {
...테스트
})

it("잔고 테스트", () => {
...테스트
})
})

위 코드는 기존 방법에 Snapshot을 적용한 코드다.

먼저, before 훅에 배포 작업을 넣으면서 테스트 케이스가 여러개여도 한번만 배포되게끔 했다.

beforeEach 훅에 snapshot Id를 저장하여 테스트 케이스 실행 전 상태를 저장하고 afterEach 훅에서 해당 snapshot으로 revert 되게끔 하여 테스트 완료 후 테스트 케이스 실행 전 상태로 돌아가게끔 했다.

이렇게 하면 다음 테스트 케이스에서는 새로 컨트랙을 배포하지 않고도 이미 배포되었지만 깨끗한 컨트랙을 사용할 수 있다.

만약, 모든 테스트 케이스에 공통으로 적용해야하는 로직이 있다면 before 훅에서 deployAndSet 함수 뒤에 작성하면 된다. 해당 로직이 적용된 후 snapshot이 찍히기 때문에 모든 테스트 케이스에 적용된다.

위 방법으로 개선시 기존 방법에서는 3분 20초정도 걸리던 테스트 시간이 17초정도로 줄어들었다.

테스트 케이스가 더 많을수록 시간 차이는 더 커질 것이다.

(심화) 캐시를 적용하여 좀 더 개선하기

사실 위 방법만으로도 속도 개선은 충분하다.

다만, 개발자로서 좀 더 최적화하는 방법에 대해 궁금해하는 독자를 위해 캐시를 적용하여 속도를 좀 더 개선하는 방법을 공유하고자 한다.

Snapshot을 사용한 방법에서는 하나의 테스트 코드 파일에서는 한번만 배포가 되지만 여러개의 테스트 코드 파일에서는 각 파일별로 한번씩 배포가 되기 때문에 이때 배포에 의한 딜레이가 생긴다.

이를 개선하기 위해 캐시를 적용하여 테스트 과정 통틀어 한번만 배포되게끔할 수 있다.

먼저 배포 로직에 캐시를 추가한다.

let isCached = false;
let contract;
const deploy = async () => {
if(isCached) {
return {
contract
}
}

...contract 배포

isCached = true;

return {
contract
}

}

isCached 플래그를 통해 배포 함수를 여러번 호출해도 최초 한번만 배포하고 이후에는 배포된 컨트랙을 반환하도록 했다.

이제 각 테스트 파일마다 before 훅을 수정하고 after 훅을 추가한다.

import { network } from "hardhat";

let contract;
let snapshotId;
let initialSnapshotId;

describe("ERC20 컨트랙 테스트", () => {

before(async () => {
// hardhat 네트워크에 배포 또는 배포된 컨트랙 조회 후 contract 변수에 배포한 컨트랙 인스턴스 주입
await deployAndSet();

// 모든 테스트 케이스 실행 후 원상복구 하기 위해 상태 저장
initialSnapshotId = await network.provider.send('evm_snapshot');

...테스트 케이스에 공통된 로직 작성
})

beforeEach(async () => {
snapshotId = await network.provider.send('evm_snapshot');
})

afterEach(async () => {
await network.provider.send("evm_revert", [snapshotId]);
})

after(async () => {
await network.provider.sned('evm_revert', [initialSnapshotId]);
})

it("전송 테스트", () => {
...테스트
})

it("잔고 테스트", () => {
...테스트
})
})

모든 테스트 케이스 실행 후 원상복구 하기 위해 initialSnapshotId 변수를 선언한다.

snapshotId가 각 테스트 케이스 격리를 위한 변수라면 initialSnapshotId는 각 테스트 파일 격리를 위한 변수라고 생각할 수 있다.

각 테스크 케이스별로 beforEach 훅에서 이전 상태를 기록하고 afterEach 훅에서 이전 상태로 돌아가는 것처럼 각 테스트 파일별로 before 훅에서 이전 상태를 기록하고 after 훅에서 이전 상태로 돌아간다.

이전에는 각 테스트 파일별로 새로 배포하기 때문에 서로 상태를 공유하지 않지만 이제는 배포를 한번만하여 모든 테스트 파일에서 동일한 컨트랙을 사용하기 때문에 각 테스트 파일에서 상태를 원상복구하는 로직이 추가되는 것이다.

기존 방법에서는 3분 20초정도 걸렸고 Snapshot을 사용한 방법에서는 17초정도로 걸렸던 테스트 시간이 캐시를 적용하니 9초정도 걸렸다.

테스트 파일이 더 많을수록 시간 차이는 더 커질 것이다.

결론

지금까지 Snapshot을 사용해 Hardhat test의 속도를 개선한 경험과 방법을 공유했다.

생각보다 간단하게 작업할 수 있지만 참고 자료가 없어 힘들었다.

블록체인 어플리케이션 개발시 테스트 시간이 오래 걸렸던 힘들었던 분들께 도움이 되길 바란다.

읽어주셔서 감사합니다 : )

--

--