Unit.. 뭐라고?
Unit Testing란, Unit이라고 부르는 논리적으로 분리 가능한 가장 작은 코드 조각을 테스트 하는 소프트웨어 테스트 기법 중 하나입니다. 개발자들은 Unit이라는 최소 단위를 어떤 수준에서 정의할지에 대해 기법과 개인의 선호도에 따라 상이할 수 있습니다. 그러나 일반적으로는 단일 Class나 Method를 Unit으로 많이 정의합니다.
기능 하나의 오작동이 전체 시스템에 치명적인 영향을 주는 Blockchain 개발과 같은 경우, Unit Testing은 많은 대형 참사를 예방하는 좋은 방법 중 하나입니다. 각 기능을 독립적으로 테스트함으로써 개발자들은 코드가 예상대로 작동하는지 확인할 수 있으며, 이를 통해 시스템의 안정성과 신뢰성을 향상시킬 수 있습니다. Unit Testing과 Software Testing에 대해 더 자세히 알고 싶다면 이 홈페이지를 참조하세요.
Cosmos Appchain 개발에 관한 자료들은 많이 있지만, 테스트를 하는 방법에 대한 충분한 자료는 부족한 편입니다. 따라서 이번에는 Cosmos Appchain에 적용 가능한 다양한 수준의 테스트 기법에 대해 3개의 글에 걸쳐 소개하고자 합니다. 첫 번째 글로서, 간단한 AMM Chain에 대해 Unit Testing을 적용하는 튜토리얼을 제공할 예정입니다.
테스트를 위한 환경 준비하기
우선, 우리가 Unit Test를 작성할 대상을 살펴봅시다. 우리는 최소의 AMM 기능만이 포함된 Appchain을 대상으로 Unit Test를 진행할 것입니다. 테스트 코드를 작성하기 전에, 우선 테스트를 수행하기 위한 테스트 체인을 초기화하고, 테스트를 도와줄 몇가지 함수를 만들어 테스트 환경을 준비할 것입니다.
테스트 체인 초기화 하기
Unit Test도 결국 초기화 된 테스트 체인에 대하여 일부분의 기능을 테스트 하는 것이기 때문에, 테스트 체인을 테스트 시에 구성해 주어야 합니다. Cosmos-SDK의 Chain Simulator 부분을 참조해서 app/test_helper.go
를 작성했습니다.
가장 중요한 부분은 결국 Setup 부분인데, 임의 테스트용 Validator 키를 만들어 Validator로 지정하고, Appchain의 상태를 저장할 MemDB를 새로 만들어서, 아무런 Log가 찍히지 않는 새로운 Appchain을 배포 하는 함수를 만든 것입니다.
테스트 Helper 작성하기
다음으로는 테스트 Suite를 저장하고, Appchain을 초기화하고 매 테스트마다 리셋해 줄 Helper를 만들어주어야 합니다. TestHelper는 현재 블록에 관한 정보를 담고 있는 Context와 App을 초기화 해주는 함수인 Setup과 TestHelper의 Context와 AppChain을 리셋해주는 Reset 함수로 이루어져 있습니다. Setup 시에는 테스트에 쓸 임시 지갑도 3개 정도 생성하도록 했습니다.
테스트 작성해 보기
Keeper들의 기능을 테스트하기위해서, KeeperTestSuite를 x/amm/keeper/keeper_test.go
에 만들어 주었습니다. KeeperTestSuite는 KeeperTestSuite에 속한 모든 테스트를 실행합니다.
이제 테스트를 작성하기 위한 준비는 거의 끝났습니다. 우리는 x/amm/keeper/pair.go
의 AddLiquidity 함수를 테스트하고자 합니다. 저는 AddLiqudity에 관해서 테스트가 필요하다고 생각되는 몇몇 기능적 특성들을 아래와 같이 정리하였습니다:
- Should fail if share is less than minimum initial liquidity (1000)
- Should fail if coin0 amount is insufficient (pool non-exist)
- Should fail if coin1 amount is insufficient (pool non-exist)
- Should properly mint/transfer coin0, coin1, share even parameter is unbalanced (pool exist)
- Should properly mint/transfer coin0, coin1, share (pool exist)
테스트케이스는 최대한 모든 경우의 수를 생각하는 것이 좋으며, 가장 간단한 방법은 코드 라인 커버리지 100%를 목표로 하는 것입니다. 하지만, 코드 커버리지에 너무 의존하는 것보다는 다양한 코너 케이스를 생각해서 넣으면 됩니다.
특정 테스트를 하기 위한 함수의 이름은 무조건 Test로 시작해야 하므로, (Go Test Convention입니다.) TestAddLiquidity 함수를 x/amm/keeper/pair_test.go
에 만듭니다. 하고자 하는 테스트는 비슷한 환경에서 반복적으로 돌아가기 때문에, 테스트에서 변하는 설정들을 멤버로 하고, 테스트 이름을 키로 하는 맵을 만듭니다. 여기서는 유동성을 공급할 양인 coins
와 pool이 이미 초기화되어 있는지 여부를 정하는 poolExist
, 에러가 나는지 여부를 정하는 expectError
정도를 멤버로 했습니다.
tests
맵을 순회하면서, AppChain을 초기화 시키고, 초기 자금을 지급한 후, AddLiquidity
를 실행하고 에러가 나는지 여부 등을 체크하도록 작성하였습니다. 여기서는 에러가 나는지 여부 등만을 검사하였지만, 검사할 수 있는 특성은 testify의 require 문서를 참조하면 더욱 다양한 특성에 대한 검사가 가능합니다.
테스트 작동시켜보기
테스트 작성이 완료되었다면, 테스트를 작동 시켜봐야 합니다. 우리는 위에서 작성한 파일들이 저장된 x/amm/keeper
디렉토리로 가서 go test -v
명령어로 테스트를 실행합니다. go test
는 Test로 시작하는 모든 함수를 테스트로 인식하여 테스트를 수행합니다. -v
옵션은 테스트에 대한 좀 더 자세한 정보를 줍니다.
테스트들이 작동하며, 테스트 통과 여부가 결과로 나옵니다. 만약 테스트가 실패한다면, 아래와 같이 원하는 결과와의 차이를 보여줍니다.
정리
이번 글에서는 간단한 AMM AppChain을 가지고 Unit Test를 작성하는 법을 알아보았습니다. 이번의 글의 내용을 요약하자면 다음과 같습니다.
- AppChain을 만드는 과정에 있어서, 테스트를 하는 것은 중요하다.
- Test Code를 짜기 전에 AppChain을 초기화 해주는 Test Helper를 만드는 과정이 필요하다.
- Test Code는 최대한 Template을 반복하는 식으로 작성해서 Testcase Map을 순회하도록 작성하는 것이 편하다.
이번 테스트에 사용된 테스트 코드들은 Gist에 있습니다. 다음 Article에는 조금 더 복잡한 모듈을 대상으로 Integrated Test를 작성하는 방법에 대해서 알아보도록 하겠습니다.
Unit… What?
Unit Testing is one of the software testing techniques used to test the smallest logically separable pieces of code called "units". The definition of a "unit" at a certain level may vary depending on the methodology and personal preferences of developers. However, a single class or method is generally defined as a unit.
In cases where a malfunction in one function can have a critical impact on the entire system, such as in Blockchain development, Unit Testing is one of the effective ways to prevent significant disasters. By testing each function independently, developers can verify that the code works as expected, thereby enhancing the system's stability and reliability. If you want to learn more about Unit Testing and Software Testing, you can refer to this website.
While there is plenty of information available on Cosmos Appchain development, there is a lack of sufficient resources on how to conduct testing. Therefore, in this series of three articles, I aim to introduce various levels of testing techniques applicable to Cosmos Appchain. In the first article, I will provide a tutorial on applying Unit Testing to a simple AMM Chain.
Preparing the Environment for Testing
First, let's examine the target for our unit test. We will conduct unit tests on a simple appchain that includes only the minimum AMM functionality. Before writing the test code, we'll initialize a test chain and create some helper functions to prepare the testing environment.
Initializing the Test Chain
Since unit tests involve testing a portion of the functionality on an initialized test chain, we need to configure the test chain for testing. We have written app/test_helper.go
by referring to the Chain Simulator section in Cosmos-SDK to assist with this task.
The most crucial part is the Setup function, where creates a random test validator key and designates it as the validator. Additionally, we create a new MemDB to store the state of the Appchain and deploy a new Appchain with no logs being recorded.
Writing Test Helpers
Next, we need to create a test suite to store and initialize the Appchain and provide a helper to reset it for each test. The TestHelper consists of a Setup function to initialize the app and a context that contains information about the current block. It also includes a Reset function to reset the Context and AppChain of the TestHelper for each test. During the Setup, we create around three temporary wallets for testing purposes.
Writing a Test
To test the functionalities of the Keepers, we have created the KeeperTestSuite in x/amm/keeper/keeper_test.go
. The KeeperTestSuite is responsible for executing all the tests belonging to the Keeper functions.
Now we are almost ready to write the tests. We want to test the AddLiquidity
function in x/amm/keeper/pair.go
. I have summarized several functional features for AddLiquidity
:
- Should fail if the share is less than the minimum initial liquidity (1000).
- Should fail if the amount of coin0 is insufficient (pool does not exist).
- Should fail if the amount of coin1 is insufficient (pool does not exist).
- Should properly mint/transfer coin0, coin1, and share even if the parameters are unbalanced (pool exists).
- Should properly mint/transfer coin0, coin1, and share (pool exists).
It's a good practice to consider as many scenarios as possible for test cases, and aiming for 100% code line coverage is a simple way to achieve this goal. However, instead of overly relying on code coverage, it's better to think of various corner cases and include them in the tests.
The name of the function for a specific test should always start with Test
as per the Go Test Convention. Therefore, we'll create the TestAddLiquidity
function in x/amm/keeper/pair_test.go
. Since the desired test will be executed in a similar environment repeatedly, we'll create a map with test names as keys, and the changing settings as members. Here, I included liquidity supply amounts (coins
), whether the pool is already initialized (poolExist
), and whether an error is expected (expectError
) as members.
The code below shows the tests to iterate through the tests map, initialize the AppChain, provide initial funds, and execute AddLiquidity
. Each test checks whether an error occurs or not. In this case, I only checked whether an error occurred, but you can refer to the documentation of testify
's require
for more diverse checks on various features.
Running the Tests
Once the test writing is complete, it's essential to run the tests to ensure they function correctly. Navigate to the x/amm/keeper
directory where the files we created are located. Then, execute the command go test -v
to run the tests. go test
recognizes all functions starting with Test
as tests and performs the testing accordingly. The -v
option provides more detailed information about the tests being executed.
The tests will run, and the test results will show whether each test passes or not. If a test fails, it will display the differences between the expected result and the actual result, indicating the discrepancies.
Summary
In this article, we explored how to write unit tests for a simple AMM AppChain. Here's a summary of the content:
- Testing during the AppChain development process is crucial.
- Creating a test helper to initialize the AppChain before writing test code is essential.
- Writing test code with repetitive templates and iterating through a test case map can be more convenient.
The test code used in this test can be found in Gist. In the next article, we will explore how to write Integrated Tests for more complex modules.