Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Anchor 테스트: TypeScript로 Solana 프로그램 테스트하기

Solana 테스트는 TypeScript로 작성한다

Anchor 프로그램의 테스트는 TypeScript로 작성합니다. Node.js 백엔드 개발자에게 매우 친숙한 환경입니다. 테스트 프레임워크는 Mocha + Chai를 사용하며, Jest와 유사한 패턴으로 작성합니다.

이더리움 테스트 스택:          Solana(Anchor) 테스트 스택:
Solidity (프로그램)            Rust + Anchor (프로그램)
Foundry (Forge) 또는           @coral-xyz/anchor (클라이언트)
Hardhat (테스트)               Mocha + Chai (테스트 러너)
JavaScript/TypeScript          TypeScript

패키지 설정

// package.json (anchor init이 자동 생성)
{
  "scripts": {
    "lint:fix": "prettier */*.js \"*/**/*{.js,.ts}\" -w",
    "lint": "prettier */*.js \"*/**/*{.js,.ts}\" --check"
  },
  "dependencies": {
    "@coral-xyz/anchor": "^0.30.1"
  },
  "devDependencies": {
    "chai": "^4.3.4",
    "mocha": "^9.0.3",
    "ts-mocha": "^10.0.0",
    "@types/bn.js": "^5.1.0",
    "@types/chai": "^4.3.0",
    "@types/mocha": "^9.0.0",
    "typescript": "^4.3.5"
  }
}
// tsconfig.json
{
  "compilerOptions": {
    "types": ["mocha", "chai"],
    "typeRoots": ["./node_modules/@types"],
    "lib": ["es2015"],
    "module": "commonjs",
    "target": "es6",
    "esModuleInterop": true
  }
}

AnchorProvider 설정

AnchorProvider는 Solana RPC 연결과 지갑(서명자)을 묶어주는 객체입니다. NestJS의 ConfigServiceDataSource 초기화와 유사한 역할입니다.

import * as anchor from "@coral-xyz/anchor";
import { Program, AnchorProvider, web3, BN } from "@coral-xyz/anchor";
import { MyProject } from "../target/types/my_project";  // 자동 생성 타입
import { assert, expect } from "chai";

describe("my-project", () => {
  // ============================================================
  // 1. Provider 설정
  // ============================================================

  // anchor test 실행 시 환경변수에서 자동 설정:
  // - ANCHOR_PROVIDER_URL: 클러스터 URL
  // - ANCHOR_WALLET: 키페어 파일 경로
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  // 접근 가능한 객체들:
  const connection = provider.connection;     // RPC 연결
  const wallet = provider.wallet;             // 기본 지갑 (payer)
  const payer = provider.wallet as anchor.Wallet;

  // ============================================================
  // 2. Program 인스턴스 생성
  // ============================================================

  // workspace: Anchor.toml에서 읽어온 프로그램 ID + IDL을 자동 로드
  const program = anchor.workspace.MyProject as Program<MyProject>;

  // 또는 수동으로:
  // const program = new Program<MyProject>(
  //   idl as MyProject,
  //   PROGRAM_ID,
  //   provider,
  // );

  console.log("프로그램 ID:", program.programId.toBase58());
  console.log("지갑 주소:", wallet.publicKey.toBase58());
});

핵심 패턴: program.methods 체인

Anchor TypeScript 클라이언트의 핵심 API입니다:

// 기본 패턴:
const txSignature = await program.methods
  .functionName(arg1, arg2)    // Instruction 이름 + 인자
  .accounts({                   // 계정 목록
    accountName: publicKey,
  })
  .signers([keypair])           // 추가 서명자 (payer 외)
  .rpc();                       // 트랜잭션 전송 + 확정 대기

// 변형 패턴들:
// .rpc()                     → 전송 후 서명 반환
// .transaction()             → Transaction 객체 반환 (나중에 전송)
// .instruction()             → Instruction 객체 반환 (직접 조합)
// .simulate()                → 시뮬레이션만 (전송 안 함)
// .prepare()                 → 서명 전 Transaction 준비

BN (BigNumber) 사용

Solana의 u64는 JavaScript의 number 범위를 초과하므로 BN을 사용합니다:

import { BN } from "@coral-xyz/anchor";

// u64 타입 인자
await program.methods
  .mintPoints(new BN(1000))    // u64 → BN
  .accounts({
    pointSystem: pointSystemPda,
    userPoint: userPointPda,
    authority: provider.wallet.publicKey,
  })
  .rpc();

// BN 연산:
const a = new BN(100);
const b = new BN(50);
console.log(a.add(b).toString());   // "150"
console.log(a.sub(b).toString());   // "50"
console.log(a.mul(b).toString());   // "5000"
console.log(a.gt(b));               // true
console.log(a.toNumber());          // 100 (안전한 범위일 때만)

계정 데이터 읽기: program.account

// 단일 계정 읽기
const accountData = await program.account.userProfile.fetch(accountPubkey);
console.log("score:", accountData.score.toString());
console.log("owner:", accountData.owner.toBase58());

// 여러 계정 읽기 (배치)
const accounts = await program.account.userProfile.fetchMultiple([
  pubkey1,
  pubkey2,
  pubkey3,
]);

// 조건으로 계정 검색 (주의: 전체 계정 스캔, 비쌈)
const allProfiles = await program.account.userProfile.all();
console.log("전체 프로필 수:", allProfiles.length);

// 필터로 검색 (memcmp: 메모리 비교)
const authorProfiles = await program.account.userProfile.all([
  {
    memcmp: {
      offset: 8,                              // discriminator(8) 건너뜀
      bytes: wallet.publicKey.toBase58(),     // owner 필드 값
    },
  },
]);

PDA 주소 계산

import { PublicKey } from "@solana/web3.js";

// Rust의 find_program_address와 동일한 결과
const [profilePda, bump] = PublicKey.findProgramAddressSync(
  [
    Buffer.from("profile"),
    wallet.publicKey.toBuffer(),
  ],
  program.programId
);

console.log("PDA:", profilePda.toBase58());
console.log("Bump:", bump);

// 여러 seeds 조합:
const [escrowPda] = PublicKey.findProgramAddressSync(
  [
    Buffer.from("escrow"),
    Buffer.from(tradeId.toString()),
    buyer.toBuffer(),
    seller.toBuffer(),
  ],
  program.programId
);

에러 테스트: AnchorError

import { AnchorError } from "@coral-xyz/anchor";

it("권한 없는 사용자는 메모를 수정할 수 없어야 함", async () => {
  const attacker = web3.Keypair.generate();

  try {
    await program.methods
      .updateMemo("해킹 시도")
      .accounts({
        memo: memoPda,
        author: attacker.publicKey,   // 잘못된 author
      })
      .signers([attacker])
      .rpc();

    // 여기까지 오면 테스트 실패
    assert.fail("에러가 발생해야 합니다");

  } catch (err) {
    // AnchorError 타입 검사
    expect(err).to.be.instanceOf(AnchorError);

    const anchorErr = err as AnchorError;

    // 에러 코드 확인 (Rust의 #[error_code] enum)
    expect(anchorErr.error.errorCode.code).to.equal("Unauthorized");
    expect(anchorErr.error.errorCode.number).to.equal(6002);

    // 에러 메시지 확인
    expect(anchorErr.error.errorMessage).to.include("권한이 없습니다");

    // 프로그램 확인
    expect(anchorErr.program.equals(program.programId)).to.be.true;
  }
});

// 또는 chai의 rejectedWith 패턴:
it("빈 메모는 생성 불가", async () => {
  await expect(
    program.methods
      .createMemo("")
      .accounts({ memo: memoPda, author: wallet.publicKey, systemProgram: web3.SystemProgram.programId })
      .rpc()
  ).to.be.rejectedWith(AnchorError, "EmptyContent");
});

anchor test 명령어와 동작 방식

# 전체 테스트 실행 (가장 많이 사용)
anchor test

# 내부 동작:
# 1. anchor build         → 프로그램 컴파일
# 2. solana-test-validator 시작 (백그라운드)
# 3. anchor deploy        → 로컬 검증자에 배포
# 4. yarn run ts-mocha ... → 테스트 실행
# 5. 검증자 종료

# 이미 실행 중인 검증자 사용 (빌드 캐시 활용)
anchor test --skip-local-validator

# 빌드 건너뛰기 (코드 변경 없을 때)
anchor test --skip-build

# 특정 테스트 파일만
anchor test tests/memo.ts

# 로컬 검증자 직접 실행 (개발 중 유지)
solana-test-validator --reset   # 초기화 후 시작

# 별도 터미널에서:
anchor deploy
anchor test --skip-local-validator --skip-build

전체 테스트 예제: 메모 프로그램

import * as anchor from "@coral-xyz/anchor";
import { Program, AnchorError, BN, web3 } from "@coral-xyz/anchor";
import { MemoProgram } from "../target/types/memo_program";
import { assert, expect } from "chai";

describe("memo-program", () => {
  const provider = anchor.AnchorProvider.env();
  anchor.setProvider(provider);

  const program = anchor.workspace.MemoProgram as Program<MemoProgram>;
  const author = provider.wallet as anchor.Wallet;

  // 테스트 전체에서 공유할 PDA
  let memoPda: anchor.web3.PublicKey;
  let memoBump: number;

  // ============================================================
  // before: 각 테스트 전 공통 설정
  // ============================================================
  before(async () => {
    // PDA 주소 계산
    [memoPda, memoBump] = anchor.web3.PublicKey.findProgramAddressSync(
      [Buffer.from("memo"), author.publicKey.toBuffer()],
      program.programId
    );
    console.log("메모 PDA:", memoPda.toBase58());
    console.log("작성자:", author.publicKey.toBase58());
  });

  // ============================================================
  // 테스트 1: 메모 생성
  // ============================================================
  it("새 메모를 생성할 수 있다", async () => {
    const content = "안녕하세요, Solana!";

    const txSig = await program.methods
      .createMemo(content)
      .accounts({
        memo: memoPda,
        author: author.publicKey,
        systemProgram: web3.SystemProgram.programId,
      })
      .rpc();

    console.log("트랜잭션:", txSig);

    // 계정 데이터 확인
    const memoAccount = await program.account.memo.fetch(memoPda);

    assert.equal(memoAccount.content, content);
    assert.isTrue(memoAccount.author.equals(author.publicKey));
    assert.isAbove(memoAccount.createdAt.toNumber(), 0);
    assert.equal(memoAccount.bump, memoBump);
  });

  // ============================================================
  // 테스트 2: 메모 조회
  // ============================================================
  it("메모를 조회할 수 있다", async () => {
    const memoAccount = await program.account.memo.fetch(memoPda);

    expect(memoAccount.content).to.equal("안녕하세요, Solana!");
    expect(memoAccount.author.toBase58()).to.equal(author.publicKey.toBase58());
  });

  // ============================================================
  // 테스트 3: 메모 업데이트
  // ============================================================
  it("작성자가 메모를 수정할 수 있다", async () => {
    const newContent = "수정된 메모 내용";

    await program.methods
      .updateMemo(newContent)
      .accounts({
        memo: memoPda,
        author: author.publicKey,
      })
      .rpc();

    const memoAccount = await program.account.memo.fetch(memoPda);
    expect(memoAccount.content).to.equal(newContent);
    expect(memoAccount.updatedAt.toNumber()).to.be.gte(
      memoAccount.createdAt.toNumber()
    );
  });

  // ============================================================
  // 테스트 4: 에러 케이스 - 빈 내용
  // ============================================================
  it("빈 내용으로 메모를 생성하면 에러가 발생한다", async () => {
    // 다른 사용자로 새 PDA 계산
    const newAuthor = web3.Keypair.generate();

    // Devnet에서 SOL 에어드롭 (로컬에서는 자동)
    const airdropSig = await provider.connection.requestAirdrop(
      newAuthor.publicKey,
      2 * web3.LAMPORTS_PER_SOL
    );
    await provider.connection.confirmTransaction(airdropSig);

    const [newMemoPda] = web3.PublicKey.findProgramAddressSync(
      [Buffer.from("memo"), newAuthor.publicKey.toBuffer()],
      program.programId
    );

    try {
      await program.methods
        .createMemo("")
        .accounts({
          memo: newMemoPda,
          author: newAuthor.publicKey,
          systemProgram: web3.SystemProgram.programId,
        })
        .signers([newAuthor])
        .rpc();

      assert.fail("에러가 발생해야 합니다");
    } catch (err) {
      expect(err).to.be.instanceOf(AnchorError);
      const anchorErr = err as AnchorError;
      expect(anchorErr.error.errorCode.code).to.equal("EmptyContent");
    }
  });

  // ============================================================
  // 테스트 5: 에러 케이스 - 권한 없는 수정
  // ============================================================
  it("다른 사용자는 메모를 수정할 수 없다", async () => {
    const attacker = web3.Keypair.generate();

    // 공격자에게 SOL 지급
    const sig = await provider.connection.requestAirdrop(
      attacker.publicKey,
      web3.LAMPORTS_PER_SOL
    );
    await provider.connection.confirmTransaction(sig);

    try {
      await program.methods
        .updateMemo("해킹!")
        .accounts({
          memo: memoPda,
          author: attacker.publicKey,   // 잘못된 author
        })
        .signers([attacker])
        .rpc();

      assert.fail("에러가 발생해야 합니다");
    } catch (err) {
      // has_one = author 제약조건 위반
      // → ConstraintHasOne 에러 또는 커스텀 Unauthorized 에러
      expect(err).to.be.instanceOf(AnchorError);
    }
  });

  // ============================================================
  // 테스트 6: 이벤트 리스닝
  // ============================================================
  it("메모 생성 시 이벤트가 발행된다", async () => {
    return new Promise<void>(async (resolve, reject) => {
      // 이벤트 리스너 등록
      const listener = program.addEventListener(
        "MemoCreated",
        (event, slot) => {
          try {
            expect(event.author.toBase58()).to.equal(
              author.publicKey.toBase58()
            );
            expect(event.timestamp.toNumber()).to.be.above(0);
            console.log("이벤트 수신! slot:", slot);
            program.removeEventListener(listener);
            resolve();
          } catch (e) {
            reject(e);
          }
        }
      );

      // 트랜잭션 전송 (이벤트 트리거)
      // 새 author로 테스트 (기존 PDA는 이미 초기화됨)
      const newAuthor = web3.Keypair.generate();
      await provider.connection.confirmTransaction(
        await provider.connection.requestAirdrop(
          newAuthor.publicKey,
          web3.LAMPORTS_PER_SOL
        )
      );
      const [newPda] = web3.PublicKey.findProgramAddressSync(
        [Buffer.from("memo"), newAuthor.publicKey.toBuffer()],
        program.programId
      );

      await program.methods
        .createMemo("이벤트 테스트")
        .accounts({
          memo: newPda,
          author: newAuthor.publicKey,
          systemProgram: web3.SystemProgram.programId,
        })
        .signers([newAuthor])
        .rpc();
    });
  });

  // ============================================================
  // 테스트 7: 계정 삭제 및 렌트 반환
  // ============================================================
  it("메모를 삭제하면 렌트가 반환된다", async () => {
    // 삭제 전 잔액
    const balanceBefore = await provider.connection.getBalance(
      author.publicKey
    );

    await program.methods
      .deleteMemo()
      .accounts({
        memo: memoPda,
        author: author.publicKey,
      })
      .rpc();

    // 삭제 후 잔액 (렌트 반환으로 증가)
    const balanceAfter = await provider.connection.getBalance(author.publicKey);
    expect(balanceAfter).to.be.greaterThan(balanceBefore);

    // 계정이 삭제되었는지 확인
    const deletedAccount = await provider.connection.getAccountInfo(memoPda);
    expect(deletedAccount).to.be.null;
  });

  // ============================================================
  // 테스트 8: 전체 계정 목록 조회
  // ============================================================
  it("모든 메모 계정을 조회할 수 있다", async () => {
    const allMemos = await program.account.memo.all();
    console.log(`현재 메모 수: ${allMemos.length}`);

    // 특정 작성자의 메모만 필터링
    const myMemos = await program.account.memo.all([
      {
        memcmp: {
          offset: 8,  // discriminator 건너뜀
          bytes: author.publicKey.toBase58(),
        },
      },
    ]);
    console.log(`내 메모 수: ${myMemos.length}`);
  });
});

유용한 테스트 유틸리티

// 트랜잭션 상세 정보 확인
async function getTransactionDetails(sig: string) {
  const tx = await provider.connection.getTransaction(sig, {
    commitment: "confirmed",
    maxSupportedTransactionVersion: 0,
  });

  if (tx?.meta?.logMessages) {
    console.log("프로그램 로그:");
    tx.meta.logMessages.forEach(log => console.log(" ", log));
  }

  if (tx?.meta?.fee) {
    console.log(`수수료: ${tx.meta.fee} lamports`);
  }
}

// 계정 존재 여부 확인
async function accountExists(pubkey: web3.PublicKey): Promise<boolean> {
  const info = await provider.connection.getAccountInfo(pubkey);
  return info !== null;
}

// SOL 잔액 확인 (SOL 단위)
async function getBalanceSOL(pubkey: web3.PublicKey): Promise<number> {
  const lamports = await provider.connection.getBalance(pubkey);
  return lamports / web3.LAMPORTS_PER_SOL;
}

// 여러 트랜잭션 동시 전송 (성능 테스트용)
async function sendParallelTransactions(count: number) {
  const promises = Array.from({ length: count }, (_, i) =>
    program.methods
      .someInstruction(i)
      .accounts({
        user: provider.wallet.publicKey,
        systemProgram: web3.SystemProgram.programId,
      })
      .rpc()
  );

  const results = await Promise.allSettled(promises);
  const succeeded = results.filter(r => r.status === "fulfilled").length;
  const failed = results.filter(r => r.status === "rejected").length;
  console.log(`성공: ${succeeded}, 실패: ${failed}`);
}

테스트 실행 및 결과 해석

$ anchor test

# 출력 예시:
  memo-program
    새 메모를 생성할 수 있다
      트랜잭션: 5KBNvBW2w...
      ✓ 새 메모를 생성할 수 있다 (1234ms)
    메모를 조회할 수 있다
      ✓ 메모를 조회할 수 있다 (456ms)
    작성자가 메모를 수정할 수 있다
      ✓ 작성자가 메모를 수정할 수 있다 (789ms)
    빈 내용으로 메모를 생성하면 에러가 발생한다
      ✓ 빈 내용으로 메모를 생성하면 에러가 발생한다 (321ms)
    다른 사용자는 메모를 수정할 수 없다
      ✓ 다른 사용자는 메모를 수정할 수 없다 (654ms)
    메모 생성 시 이벤트가 발행된다
      이벤트 수신! slot: 42
      ✓ 메모 생성 시 이벤트가 발행된다 (987ms)
    메모를 삭제하면 렌트가 반환된다
      ✓ 메모를 삭제하면 렌트가 반환된다 (543ms)
    모든 메모 계정을 조회할 수 있다
      현재 메모 수: 3
      내 메모 수: 1
      ✓ 모든 메모 계정을 조회할 수 있다 (234ms)

  8 passing (5s)

다음 장에서는 배운 모든 것을 종합하여 포인트 시스템 미니 프로젝트를 구현합니다.