Alchemy SDK API 함수 유닛 테스트

Gaeun·2023년 3월 7일
0

코드의 안정성과 신뢰성을 보장하기 위해서는 테스트 코드의 작성이 필수이며, 모든 함수가 테스트되도록 해야 한다.

하지만 위의 스크린샷에서 볼 수 있듯, 한 파일에 대한 %Func이 0%였다. 이럴수가. 분명 모든 파일을 커버했다고 생각했는데... 부랴부랴 코드를 살펴보니 도대체 내가 어떻게 이 함수를 테스트해야 좋을지에 대한 생각이 하나도 나지 않았다.

Alchemy SDK를 사용하여 블록체인 암호화폐 네트워크와 상호작용하는 API 함수에 대한 코드는 (처음에는) 이렇게 작성하였다.

1. 1차 alchemy.js 코드

// utils/alchemy.js
const { Network, Alchemy } = require('alchemy-sdk');

const getNFTs = async (walletAddress) => {
  const settings = {
    apiKey: process.env.ALCHEMY_API_KEY,
    network: Network.ETH_MAINNET,
  };

  const alchemy = new Alchemy(settings);

  const nfts = await alchemy.nft.getNftsForOwner(`${walletAddress}`);
  return nfts;
};

module.exports = { getNFTs };

그동안 엔드포인트를 호출하고 그에 대한 테스트 코드만 작성해봤지, SDK를 사용한 API에 대한 함수를 테스트해본 적은 없어 어떻게 해야할지 정말 감도 안 잡혔다.

일단, alchemy.js에서 nft 변수가 어떻게 반환되는지 console.log로 다시 한번 확인해보았다.

console.log(nfts)

/*
{
  ownedNfts: [
    {
      contract: {
        address: 'smart_contract_address',
        name: 'NFT name',
        . . .
      },
      tokenId: '1',
    },
    {
      contract: {
        address: 'smart_contract_address',
        name: 'NFT name',
        . . .
      },
      tokenId: '162',
    },
    . . .
  ],
  totalCount: 13,
};
*/

이 중 내가 필요한 것은 Smart Contract Address, Token ID, Total Count 세 개에 대한 정보이다. 이 세 정보를 위주로 목데이터를 작성하였다.

1-1. 1차 alchemy.test.js 코드

const { getNFTs } = require('../utils/alchemy');
const { Alchemy, Network } = require('alchemy-sdk');

jest.mock('alchemy-sdk');

describe('ALCHEMY.JS TEST', () => {
  test('SUCCESS: RETURN CORRECT NFTs', async () => {
    const mockGetNftsForUser = jest.fn().mockResolvedValue(mockNfts);

    Alchemy.mockImplementation(() => {
      return {
        nft: {
          getNftsForOwner: mockGetNftsForUser,
        },
      };
    });

    const walletAddress = 'testWalletAddress';
    const nfts = await getNFTs(walletAddress);

    expect(Alchemy).toHaveBeenCalledWith({
      apiKey: process.env.ALCHEMY_API_KEY,
      network: Network.ETH_MAINNET,
    });

    expect(mockGetNftsForUser).toHaveBeenCalledWith(walletAddress);
    expect(nfts).toEqual(mockNfts);
  });

  test('SUCCESS: RETURN EMPTY NFTs ARRAY WHEN NO NFT EXISTS', async () => {
    const mockGetNftsForUser = jest.fn().mockResolvedValue([]);

    Alchemy.mockImplementation(() => {
      return {
        nft: {
          getNftsForOwner: mockGetNftsForUser,
        },
      };
    });

    const walletAddress = 'testWalletAddress';
    const nfts = await getNFTs(walletAddress);

    expect(Alchemy).toHaveBeenCalledWith({
      apiKey: process.env.ALCHEMY_API_KEY,
      network: Network.ETH_MAINNET,
    });

    expect(mockGetNftsForUser).toHaveBeenCalledWith(walletAddress);
    expect(nfts).toEqual([]);
  });
});

const mockNfts = {
  ownedNfts: [
    {
      contract: {
        address: 'testAddress1',
        name: 'testContractName1',
      },
      tokenId: '1',
    },
    {
      contract: {
        address: 'testAddress2',
        name: 'testContractName2',
      },
      tokenId: '2',
    },
  ],
  totalCount: 2,
};

1차 코드에 대한 설명

1. jest.fn().mockResolvedValue(value)

const mockGetNftsForUser = jest.fn().mockResolvedValue(mockNfts);
  • jest.fn()은 Jest에서 제공하는 mock 함수를 생성하는 함수이다.
  • mockGetNftsForUser 변수에는 jest.fn()으로 생성된 mock 함수가 할당된다.
  • mockFn.mockResolvedValue(value)는 Promise를 반환하는 함수의 mock 함수를 생성할 때 사용된다. 이 함수는 Promise가 성공 상태일 때 반환할 값을 인자로 받아서, 해당 값을 Promise의 결과로 반환하는 mock 함수를 생성한다.

따라서 mockGetNftsForUser 함수는 mockNfts 값을 Promise의 결과로 반환하도록 설정된다. 이는 테스트 코드에서 getNFTs 함수 내에서 alchemy.nft.getNftsForOwner() 함수가 호출될 때, 해당 mock 함수가 mockNfts 값을 반환하도록 하여 테스트 코드가 해당 값이 반환되는지 확인할 수 있도록 한다.

2. mockFn.mockImplementation(fn)

Alchemy.mockImplementation(() => {
  return {
    nft: {
      getNftsForOwner: mockGetNftsForUser,
    },
  };
});

mockFn.mockImplementation(fn) 메소드를 사용해서 Alchemy 클래스의 생성자를 가로채고 대신에 임의의 객체를 반환하는 코드이다.

  • mockImplementation 메소드는 jest에서 mock 함수나 객체를 생성할 때 사용하는 함수 중 하나이다.
  • 이 함수를 사용하면 mock 함수나 객체가 호출되었을 때 무엇을 반환할지 지정할 수 있습니다.

위의 코드에서는 mockImplementation 메소드를 사용해서 Alchemy 클래스의 생성자를 가로채고, 대신에 다음과 같은 객체를 반환하도록 지정하였다.

{
  nft: {
    getNftsForOwner: mockGetNftsForUser,
  },
}

이 객체는 nft 프로퍼티를 가지고 있으며, 그 값으로는 다시 getNftsForOwner 메소드를 가지고 있는 객체를 반환하고 있다. 이 getNftsForOwner 메소드는 미리 만들어 둔 mockGetNftsForUser 함수를 참조하고 있다.

위 코드에서는 Alchemy 클래스의 인스턴스를 생성할 때 getNftsForOwner 메소드를 호출하면, 미리 정의해 둔 mockGetNftsForUser 함수가 실행되어 그 결과값이 반환된다. 이렇게 함으로써, Alchemy SDK를 직접 사용하지 않고도 getNftsForOwner 메소드의 반환값을 원하는 값으로 지정해 줄 수 있다. 이를 통해 테스트 시에도 예상한 값으로 코드가 동작하는지 확인할 수 있다.

3. expect(. . . )

expect
When you're writing tests, you often need to check that values meet certain conditions. expect gives you access to a number of "matchers" that let you validate different things.

expect(Alchemy).toHaveBeenCalledWith({
   apiKey: process.env.ALCHEMY_API_KEY,
   network: Network.ETH_MAINNET,
});
expect(mockGetNftsForUser).toHaveBeenCalledWith(walletAddress);
expect(nfts).toEqual(mockNfts);

이 부분은 이전에 mock으로 구현한 Alchemy 객체와 mockGetNftsForUser 함수를 사용하여 getNFTs 함수가 제대로 동작하는지를 테스트하는 코드이다.

.toHaveBeenCalledWith

  • expect(Alchemy).toHaveBeenCalledWith은 Alchemy 클래스의 생성자에 전달되는 인자를 검증하는 부분이다.
    • expect(Alchemy).toHaveBeenCalledWith에 전달된 객체와 Alchemy 객체가 생성될 때 전달된 객체가 일치해야 테스트가 성공한다.
    • 여기서는 apiKey와 network 속성을 검증하고 있다.
  • expect(mockGetNftsForUser).toHaveBeenCalledWith
    • Alchemy 클래스가 mock으로 구현되었기 때문에 nft.getNftsForOwner() 함수를 호출할 때, 실제로는 mockGetNftsForUser 함수가 실행된다.
    • 따라서 expect(mockGetNftsForUser).toHaveBeenCalledWith(walletAddress) 은, getNFTs 함수가 실행될 때 walletAddress가 mockGetNftsForUser 함수에 전달되는지를 검증하는 것이다.
  • expect(nfts).toEqual(mockNfts)
    • getNFTs 함수가 제대로 동작하는지를 검증한다.
    • 이전에 mock으로 구현한 mockNfts 객체와 getNFTs 함수가 반환한 nfts 객체를 비교하여 두 객체가 일치해야 테스트가 성공한다.

NFT를 가지고 있는 경우 외에도 사용자가 NFT를 하나도 가지고 있지 않아 빈 배열로 반환하는 경우 또한 mockResolvedValue()에 빈 배열을 목데이터로 만들어 검증하였다.

1차 코드의 문제점

  • utils/alchemy.js를 토대로 작성한 것이라 성공 케이스만 테스트되었다. 성공 케이스만 테스트한 이유는 alchemy.js에서 예외 상황에 대한 처리가 없었기 때문이다.
  • 따라서 이를 기반으로 적절한 인자가 전달되지 않은 경우, 지갑 주소가 유효하지 않은 경우에 대한 에러 핸들링을 추가하였다.

2. 2차 alchemy.js 코드

const { Network, Alchemy } = require('alchemy-sdk');
const Web3 = require('web3');

async function validateAddress(address) {
  if (!address || !Web3.utils.isAddress(address)) {
    throw new Error('Invalid Wallet Address');
  }
}

const getNFTs = async (walletAddress) => {
  const settings = {
    apiKey: process.env.ALCHEMY_API_KEY,
    network: Network.ETH_MAINNET,
  };

  try {
    await validateAddress(walletAddress);
  } catch (error) {
    error.statusCode = 400;
    throw error;
  }

  const alchemy = new Alchemy(settings);

  const nfts = await alchemy.nft.getNftsForOwner(`${walletAddress}`);
  return nfts;
};

module.exports = { getNFTs };

언급한 문제점을 바탕으로 에러 처리를 위한 validateAddress라는 함수를 추가하였다. 이는 인자가 들어오지 않거나, undefined, null 혹은 42자의 유효한 지갑 주소가 아닌 경우 에러로 처리할 수 있게 하였다.

2-1. 추가한 alchemy.test.js 코드

test('FAILED: MISSING WALLET ADDRESS', async () => {
  await expect(getNFTs()).rejects.toThrowError('Invalid Wallet Address');
});

test('FAILED: INVALID WALLET ADDRESS', async () => {
  const invalidWalletAddress = 'invalid_wallet_address';

  await expect(getNFTs(invalidWalletAddress)).rejects.toThrowError(
    'Invalid Wallet Address'
  );
});

추가한 alchemy.js를 바탕으로 위 코드를 추가하였다.

추가한 코드에 대한 설명

getNFTs() 함수의 인자로 올바르지 않은 값이 들어왔을 때, validateAddress() 함수가 에러를 throw하는 것을 테스트하기 위한 코드이다.

test('FAILED: MISSING WALLET ADDRESS', async () => {
  await expect(getNFTs()).rejects.toThrowError('Invalid Wallet Address');
});

getNFTs 함수는 walletAddress라는 인자를 받아와서 해당 지갑 주소의 NFT 정보를 반환한다. 따라서 walletAddress가 필수적인 인자이다.

위 테스트 케이스는 walletAddress가 주어지지 않았을 때, 함수가 예상한대로 에러를 반환하는지 검증하는 코드이다. await expect(getNFTs()).rejects 구문은 getNFTs 함수가 실행되었을 때, 함수가 Promise.reject를 호출하는 것을 기대하고, 해당 Promise가 reject 되면 테스트가 통과한다.

toThrow()는 reject 되는 Promise가 특정 에러를 던지는 것을 예상하고, 해당 에러가 제대로 던져졌는지 검증하는 메서드이다. 따라서 toThrow의 인자로는 해당 에러를 문자열 형태로 전달한다. 위 테스트 코드의 경우에는 Invalid Wallet Address라는 문자열을 가진 에러가 전달된다.

test('FAILED: INVALID WALLET ADDRESS', async () => {
  const invalidWalletAddress = 'invalid_wallet_address';

  await expect(getNFTs(invalidWalletAddress)).rejects.toThrowError(
    'Invalid Wallet Address'
  );
});

두 번째 테스트 실패 케이스인 위 코드는 유효하지 않은 지갑 주소가 인자로 전달되었을 때, validateAddress() 함수가 에러를 throw하는 것을 검증한다.

3. LGTM! 그리고 깨달은 TDD의 중요성

짠! 100%가 되었다!

TDD는 개발자들이 코드를 작성할 때 생각하지 않았던 예외 상황이나 문제점을 미리 발견하고, 이를 해결하기 위한 방법을 찾아내는 방법 중 하나이다. 이를 통해 코드의 안정성과 신뢰성을 높일 수 있으며, 개발 과정에서 발생하는 문제를 미리 예방할 수 있다.

특히 TDD는 테스트 코드를 작성하고 이를 통과시키는 작업을 먼저 수행하고, 그 후에 비로소 코드를 작성하게 된다. (나는 그러질 못했지만 말이다.) 이를 통해 코드의 기능이 올바르게 동작하는지 검증할 수 있다. 또한 TDD는 코드의 유지보수성을 높이는 데에도 큰 도움이 된다. 새로운 기능을 추가하거나 기존 코드를 수정할 때, 이전에 작성한 테스트 코드를 실행해보면서 수정 사항이 코드 전체에 미치는 영향을 미리 파악할 수 있다.

Jest는 TDD를 위한 다양한 기능을 제공한다. 이를 이용하면 테스트 코드 작성의 효율성과 정확성을 높일 수 있다. 예를 들어, expect 함수를 이용하여 특정 값을 비교하거나, toThrow 함수를 이용하여 예외 상황을 검증할 수 있다.

결론적으로 TDD와 Jest를 이용하여 개발을 진행하면 코드의 안정성과 신뢰성을 높일 수 있으며, 유지보수성도 향상시킬 수 있다. 이를 통해 개발자는 더 나은 코드를 작성할 수 있고, 사용자는 안정적이고 신뢰성 높은 서비스를 경험할 수 있을 것이다.

profile
🌱 새싹 개발자의 고군분투 코딩 일기

0개의 댓글