KR |  EN

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를 작성했습니다.

func (s *KeeperTestHelper) Setup() {
	dir, err := os.MkdirTemp("", "stakingammd-test-home")
	if err != nil {
		panic(fmt.Sprintf("failed creating temporary directory: %v", err))
	}
	s.T().Cleanup(func() { os.RemoveAll(dir); s.withCaching = false })
	s.App = app.Setup(s.T(), false)
	s.setupGeneral()
}

func (s *KeeperTestHelper) setupGeneral() {
	s.Ctx = s.App.BaseApp.NewContext(false, tmtypes.Header{Height: 1, ChainID: "stakingamm-1"})

	s.QueryHelper = &baseapp.QueryServiceTestHelper{
		GRPCQueryRouter: s.App.GRPCQueryRouter(),
		Ctx:             s.Ctx,
	}

	s.TestAccs = CreateRandomAccounts(3)
	s.TestAccs = append(s.TestAccs, baseTestAccts...)
}

func (s *KeeperTestHelper) Reset() {
	s.Setup()
}

가장 중요한 부분은 결국 Setup 부분인데, 임의 테스트용 Validator 키를 만들어 Validator로 지정하고, Appchain의 상태를 저장할 MemDB를 새로 만들어서,  아무런 Log가 찍히지 않는 새로운 Appchain을 배포 하는 함수를 만든 것입니다.

테스트 Helper 작성하기

다음으로는 테스트 Suite를 저장하고, Appchain을 초기화하고 매 테스트마다 리셋해 줄 Helper를 만들어주어야 합니다. TestHelper는 현재 블록에 관한 정보를 담고 있는 Context와 App을 초기화 해주는 함수인 Setup과 TestHelper의 Context와 AppChain을 리셋해주는 Reset 함수로 이루어져 있습니다. Setup 시에는 테스트에 쓸 임시 지갑도 3개 정도 생성하도록 했습니다.

func (s *KeeperTestHelper) Setup() {
	dir, err := os.MkdirTemp("", "stakingammd-test-home")
	if err != nil {
		panic(fmt.Sprintf("failed creating temporary directory: %v", err))
	}
	s.T().Cleanup(func() { os.RemoveAll(dir); s.withCaching = false })
	s.App = app.Setup(s.T(), false)
	s.setupGeneral()
}

func (s *KeeperTestHelper) setupGeneral() {
	s.Ctx = s.App.BaseApp.NewContext(false, tmtypes.Header{Height: 1, ChainID: "stakingamm-1"})

	s.QueryHelper = &baseapp.QueryServiceTestHelper{
		GRPCQueryRouter: s.App.GRPCQueryRouter(),
		Ctx:             s.Ctx,
	}

	s.TestAccs = CreateRandomAccounts(3)
	s.TestAccs = append(s.TestAccs, baseTestAccts...)
}

func (s *KeeperTestHelper) Reset() {
	s.Setup()
}

테스트 작성해 보기

Keeper들의 기능을 테스트하기위해서, KeeperTestSuite를 x/amm/keeper/keeper_test.go에 만들어 주었습니다. KeeperTestSuite는 KeeperTestSuite에 속한 모든 테스트를 실행합니다.

package keeper_test

import (
	"testing"
	"github.com/stretchr/testify/suite"

	"github.com/soohoio/stayking-template-chain/x/amm/types"
	"github.com/soohoio/stayking-template-chain/app/apptesting"
)

type KeeperTestSuite struct {
	apptesting.KeeperTestHelper
	queryClient types.QueryClient
}

func TestKeeperTestSuite(t *testing.T) {
	suite.Run(t, new(KeeperTestSuite))
}

func (s *KeeperTestSuite) SetupTest() {
	s.Reset()
	s.queryClient = types.NewQueryClient(s.QueryHelper)
}

이제 테스트를 작성하기 위한 준비는 거의 끝났습니다. 우리는 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 정도를 멤버로 했습니다.

func (s *KeeperTestSuite) TestAddLiquidity() {
	const ()
	tests := map[string]struct {
		coins 		sdk.Coins
		poolExist 	bool
		expectError bool
	}{
		"Should fail if share is less than minimum initial liquidity (1000)": {
			coins: sdk.NewCoins(
				sdk.NewCoin("stake", sdk.NewInt(100)),
				sdk.NewCoin("atom", sdk.NewInt(1)),
			),
			poolExist:  false,
			expectError: true,
		},
	...

tests 맵을 순회하면서, AppChain을 초기화 시키고, 초기 자금을 지급한 후, AddLiquidity를 실행하고 에러가 나는지 여부 등을 체크하도록 작성하였습니다. 여기서는 에러가 나는지 여부 등만을 검사하였지만, 검사할 수 있는 특성은 testify의 require 문서를 참조하면 더욱 다양한 특성에 대한 검사가 가능합니다.

for name, tc := range tests {
s.Run(name, func() {
	s.SetupTest()
	initCoins := sdk.NewCoins(
		sdk.NewCoin("stake", sdk.NewInt(10000)),
		sdk.NewCoin("atom", sdk.NewInt(100)),
	)

	funder := s.TestAccs[2]
	s.FundAcc(funder, initCoins)
	k := s.App.AMMKeeper

	if tc.poolExist {
		pair := types.NewPair(0, "atom", "stake")
		k.SetPair(s.Ctx, pair)
		k.SetPairIndex(s.Ctx, pair)
		reserveAddr := types.PairReserveAddress(pair)
		s.FundAcc(reserveAddr, initCoins)
		shareDenom := types.ShareDenom(pair)
		s.App.BankKeeper.MintCoins(s.Ctx, types.ModuleName, sdk.NewCoins(sdk.NewCoin(shareDenom, sdk.NewInt(1000))))

		mintedShare, err := k.AddLiquidity(s.Ctx, funder, tc.coins)

		if tc.expectError {
			s.Require().Error(err)
			return
		}

		s.Require().NoError(err)
		s.Require().NotNil(mintedShare)

		return

테스트 작동시켜보기

테스트 작성이 완료되었다면, 테스트를 작동 시켜봐야 합니다. 우리는 위에서 작성한 파일들이 저장된 x/amm/keeper 디렉토리로 가서 go test -v 명령어로 테스트를 실행합니다. go test는 Test로 시작하는 모든 함수를 테스트로 인식하여 테스트를 수행합니다. -v 옵션은 테스트에 대한 좀 더 자세한 정보를 줍니다.

=== RUN   TestKeeperTestSuite
=== RUN   TestKeeperTestSuite/TestAddLiquidity
=== RUN   TestKeeperTestSuite/TestAddLiquidity/Should_fail_if_coin0_amount_is_insufficient_(pool_non-exist)
=== RUN   TestKeeperTestSuite/TestAddLiquidity/Should_fail_if_coin1_amount_is_insufficient_(pool_non-exist)
=== RUN   TestKeeperTestSuite/TestAddLiquidity/Should_properly_mint/transfer_coin0,_coin1,_share_(pool_non-exist)
=== RUN   TestKeeperTestSuite/TestAddLiquidity/Should_properly_mint/transfer_coin0,_coin1,_share_even_parameter_is_unbalanced_(pool_exist)
=== RUN   TestKeeperTestSuite/TestAddLiquidity/Should_properly_mint/transfer_coin0,_coin1,_share_(pool_exist)
=== RUN   TestKeeperTestSuite/TestAddLiquidity/Should_fail_if_share_is_less_than_minimum_initial_liquidity_(1000)
--- PASS: TestKeeperTestSuite (0.04s)
    --- PASS: TestKeeperTestSuite/TestAddLiquidity (0.04s)
        --- PASS: TestKeeperTestSuite/TestAddLiquidity/Should_fail_if_coin0_amount_is_insufficient_(pool_non-exist) (0.01s)
        --- PASS: TestKeeperTestSuite/TestAddLiquidity/Should_fail_if_coin1_amount_is_insufficient_(pool_non-exist) (0.00s)
        --- PASS: TestKeeperTestSuite/TestAddLiquidity/Should_properly_mint/transfer_coin0,_coin1,_share_(pool_non-exist) (0.01s)
        --- PASS: TestKeeperTestSuite/TestAddLiquidity/Should_properly_mint/transfer_coin0,_coin1,_share_even_parameter_is_unbalanced_(pool_exist) (0.00s)
        --- PASS: TestKeeperTestSuite/TestAddLiquidity/Should_properly_mint/transfer_coin0,_coin1,_share_(pool_exist) (0.00s)
        --- PASS: TestKeeperTestSuite/TestAddLiquidity/Should_fail_if_share_is_less_than_minimum_initial_liquidity_(1000) (0.01s)

테스트들이 작동하며, 테스트 통과 여부가 결과로 나옵니다. 만약 테스트가 실패한다면, 아래와 같이 원하는 결과와의 차이를 보여줍니다.

pair_test.go:130:
    Error Trace: /works/stayking-template-chain/x/amm/keeper/pair_test.go:130
          /works/stayking-template-chain/x/amm/keeper/suite.go:91
    Error:   Not equal:
          expected: types.Coins{types.Coin{Denom:"atom", Amount:math.Int{i:(*big.Int)(0x140018a8900)}}, types.Coin{Denom:"stake", Amount:math.Int{i:(*big.Int)(0x140018a8940)}}}
          actual : types.Coins{types.Coin{Denom:"stake", Amount:math.Int{i:(*big.Int)(0x140018a92e0)}}}
          Diff:
          --- Expected
          +++ Actual
          ...

정리

이번 글에서는 간단한 AMM AppChain을 가지고 Unit Test를 작성하는 법을 알아보았습니다. 이번의 글의 내용을 요약하자면 다음과 같습니다.

  • AppChain을 만드는 과정에 있어서, 테스트를 하는 것은 중요하다.
  • Test Code를 짜기 전에 AppChain을 초기화 해주는 Test Helper를 만드는 과정이 필요하다.
  • Test Code는 최대한 Template을 반복하는 식으로 작성해서 Testcase Map을 순회하도록 작성하는 것이 편하다.

이번 테스트에 사용된 테스트 코드들은 Gist에 있습니다. 다음 Article에는 조금 더 복잡한 모듈을 대상으로 Integrated Test를 작성하는 방법에 대해서 알아보도록 하겠습니다.