
Solana 프로그램 테스트 가이드
소개
블록체인 환경의 테스트는 전통적인 소프트웨어 테스트 패러다임을 넘어 고유한 과제와 더 큰 결과를 수반합니다. 처리량이 높고 지연 시간이 짧은 Solana 환경에서는 오차 범위가 좁습니다. 자동화 테스트는 단순한 모범 사례가 아닙니다. 역동적이고 가혹한 Solana 환경에서 작동하는 프로그램의 안정성과 보안을 보장하기 위한 필수 요소입니다.
이 글에서는 자동화 테스트의 핵심 유형인 단위 테스트, 통합 테스트, 엔드투엔드(E2E) 테스트를 살펴봅니다. 또한 JavaScript/TypeScript와 Rust로 기본 단위 테스트를 작성하는 방법을 알아본 뒤, 널리 사용되는 Solana 테스트 프레임워크를 분석합니다. 마지막으로 “King of the Hill” 게임 프로그램을 테스트하는 실전 예제를 다룹니다.
이 글은 Solana의 프로그래밍 모델과 프로그램 개발에 대한 지식이 있다고 가정합니다. 프로그램을 구축하는 과정이나 Solana 고유의 개념은 다루지 않습니다. Solana 프로그램의 테스트 방법을 익히는 데 집중합니다.
Solana를 처음 접한다면 먼저 다음 블로그 게시물을 읽어보세요.
이 글은 이전에 게시한 Solana 프로그램 보안 관련 글도 보완합니다. 두 글을 함께 읽어보시기를 권합니다.
테스트란 무엇인가요?
테스트는 코드 일부나 애플리케이션 전체가 의도대로 작동하는지 검증하는 과정입니다. 테스트는 크게 두 가지 유형으로 나뉩니다.
- 수동 테스트: 개발자, 품질 보증 분석가, 침투 테스터 또는 담당자가 테스트 사례를 직접 실행하는 사람 중심의 프로세스입니다
- 자동화 테스트: 미리 정의된 테스트 사례를 프로그래밍 방식으로 실행하도록 스크립트를 작성하는 코드 중심의 프로세스입니다
수동 테스트는 테스트 대상 애플리케이션 유형과 관계없이 매우 유연합니다. 새로운 기능, 사용성, 접근성을 테스트하는 데 적합합니다. 수동 테스트는 애플리케이션이 어떻게 작동해야 하는지에 대한 테스터의 직관에 의존합니다. 하지만 테스트 과정에 활용되는 도구가 부족하므로 본질적으로 느리고 오류가 발생하기 쉬우며 시간이 많이 들고, 모든 시나리오를 다루지 못해 불완전한 경우가 많습니다.
자동화 테스트는 수동 테스트의 단점을 해결하고자 합니다. 일반적으로 더 빠르며, 특히 테스트를 병렬로 실행할 때 효과적입니다. 미리 정의된 스크립트를 따르므로 사람의 실수에도 덜 취약합니다. 많은 테스트 사례를 효율적으로 처리할 수 있어 테스트 범위를 넓히며, 확장성이 매우 높은 솔루션입니다. 다만 자동화 테스트는 엄격하고 객관적이므로 사람의 상호작용, 판단 또는 비판적 추론이 필요한 테스트에서는 정확도가 떨어집니다.
이 글에서는 Solana 프로그램의 자동화 테스트에 집중합니다. mainnet에서 수동으로 테스트하면 비용이 많이 들고, devnet에서 수동으로 테스트하면 상당한 시간이 필요하기 때문입니다. 하지만 코드를 프로덕션에 배포하기 전 테스트 과정에는 수동 테스트와 자동화 테스트가 모두 포함되어야 합니다. 견고한 테스트 프로세스는 개발 초기에 버그를 찾아 프로덕션에 유입되는 버그를 최소화합니다.
자동화 테스트에는 다음과 같은 여러 유형이 있습니다.
- 단위 테스트
- 통합 테스트
- 엔드투엔드(E2E) 테스트
단위 테스트
단위 테스트는 코드에서 가장 작은 기능 단위를 검사해 올바르게 작동하는지 확인하는 과정입니다. 이상적인 단위는 개별 함수나 모듈처럼 프로그램을 구성하는 가장 작은 빌딩 블록입니다. 이 단위들이 결합되어 최종 제품을 만듭니다. 핵심 개념은 각 빌딩 블록을 철저히 테스트하면 전체 프로그램도 의도대로 작동한다는 것입니다.
단위 테스트는 특정 프로그램의 각 부분이 의도대로 작동하도록 보장하므로 Solana 개발의 기반이 됩니다. 테스트를 재사용하면 새로운 기능이나 업데이트가 테스트 사례에 정의된 프로젝트 사양과 사용자 기대를 충족하는지 확인할 수 있습니다. 따라서 새로운 개선 사항이 프로그램 기능에 악영향을 주지 않도록 단위 테스트는 자연스럽게 코드 최적화와 리팩터링을 장려합니다. 단위 테스트는 다양한 테스트 조건에서 각 코드 구간이 올바르게 작동하는지 보장할 뿐 아니라 효율적이고 안전한 블록체인 상호작용도 보장합니다. 단위 테스트로 버그를 조기에 발견하는 일은 잠재적 취약점이 프로덕션에 유입되는 것을 막는 데 매우 중요합니다.
다양한 테스트 프레임워크는 네트워크 조건 시뮬레이션과 프로그램 상태 관리를 간소화해 단위 테스트를 효율화합니다. 이 글의 뒷부분에서 자세히 살펴보겠습니다. Solana 개발자는 단위 테스트를 통해 높은 수준의 코드 안정성과 성능을 확보할 수 있습니다.
통합 테스트
통합 테스트는 단위 테스트를 넘어 프로그램의 서로 다른 단위가 함께 작동하는 방식을 검사합니다. 프로그램 간 상호작용이 복잡하고 재정적 결과를 수반하는 경우가 많은 Solana 개발에서는 프로그램의 함수와 모듈이 올바르게 연동되는지 검증하는 일이 중요합니다. 통합 테스트는 단위를 개별적으로 테스트할 때는 쉽게 드러나지 않지만 구성 요소가 상호작용할 때 발생하는 문제를 식별하고 해결하는 것을 목표로 합니다. 데이터 형식 불일치, 타입 불일치, 프로그램 종속성 또는 서드 파티 API 문제가 이에 해당합니다.
프로그램이 본질적으로 다른 프로그램, 지갑, 오라클과 상호작용하는 Solana에서 통합 테스트는 이러한 상호작용이 의도대로 이루어지는지 검증합니다. 각 단위가 완벽하게 작동하더라도 이들을 결합하면 시뮬레이션 조건에서 예기치 않은 동작이나 비효율이 나타날 수 있습니다. 개발자는 다양한 테스트 프레임워크를 사용해 여러 트랜잭션 흐름과 프로그램 상호작용을 시뮬레이션하고 실제 시나리오를 가깝게 재현할 수 있습니다. 예를 들어 Bankrun은 시간을 앞뒤로 이동하고 계정 데이터를 동적으로 설정할 수 있는 견고하고 가벼운 테스트 프레임워크입니다. solana-test-validator로는 이러한 작업을 수행할 수 없습니다. 프로그램이 견고하고 안정적이며 Solana 네트워크 조건의 요구 사항에 대비되었는지 확인하려면 통합 테스트가 필수입니다.
엔드투엔드(E2E) 테스트
엔드투엔드(E2E) 테스트는 테스트 프로세스의 최종 단계입니다. 실제 시나리오에서 발생하는 프로그램의 전체 작동 흐름을 평가하는 데 집중합니다. 이 테스트 방법은 사용자 관점에서 프로그램을 검사한다는 점에서 단위 테스트 및 통합 테스트와 다릅니다. 최종 사용자가 접할 수 있는 모든 흐름과 기능이 의도대로 작동해야 합니다.
E2E 테스트는 프로그램이 기능 요구 사항을 충족하고 원활한 사용자 경험을 제공하는지 검증하는 데 필수적입니다. 이 테스트 단계에서는 트랜잭션 처리 지연, 상태 유지 문제, 컴퓨트 유닛 최적화 또는 예기치 않은 네트워크 조건 등 단위 테스트나 통합 테스트에서 드러나지 않았던 문제를 찾을 수 있습니다. E2E 테스트는 일반적으로 전체 dApp을 대상으로 하지만, 성공적이고 안전한 프로그램을 구축하려면 프로그램의 작동 흐름을 테스트하고 사용자 트랜잭션이 다양한 함수 및 모듈과 상호작용하는 방식을 검증해야 합니다.
테스트 방법론 결합하기
개발 프로세스에서는 계층형 테스트 전략을 사용하고 단위 테스트, 통합 테스트, E2E 테스트를 결합해야 합니다. 각 테스트 방법론은 개발 수명 주기에서 고유한 역할을 하며 프로그램 기능과 성능의 서로 다른 측면을 다룹니다.
단위 테스트는 계층형 테스트 접근 방식의 기반입니다. 개발자는 가장 세분화된 코드 수준에서 문제를 빠르게 식별하고 해결할 수 있습니다. 개별 함수나 모듈의 객관적 정확성을 보장하는 데는 탁월하지만, 이러한 단위가 서로 어떻게 작동하거나 사용자 경험에 어떻게 통합되는지는 고려하지 못합니다.
통합 테스트는 서로 다른 단위의 상호작용을 평가하고 구성 요소를 결합할 때 발생하는 문제를 찾아 이러한 간극을 메웁니다. 하지만 통합 테스트만으로는 최종 사용자 경험이나 실제 조건에서의 프로그램 동작을 완전히 파악하지 못할 수 있습니다.
E2E 테스트는 실제 사용자 시나리오를 시뮬레이션하고 애플리케이션 전체를 테스트해 단위 테스트와 통합 테스트를 보완합니다. 이 접근 방식은 전체 사용자 경험을 평가하는 데 매우 유용하지만, 특정 문제를 빠르게 식별하고 해결하는 데 필요한 세부적인 인사이트는 제공하지 못합니다.
개발자는 이러한 방법론을 결합해 발생 가능한 문제의 전체 범위를 다루는 견고한 테스트 프레임워크를 구축할 수 있습니다. 포괄적인 접근 방식은 프로그램의 품질과 보안을 강화할 뿐 아니라 개발 프로세스도 간소화합니다. 개발자는 변경 사항이 여러 단계에서 검증된다는 확신을 바탕으로 빠르게 정보를 파악하고 수정할 수 있습니다. 배포 전에 Solana 프로그램이 기술적으로 건전하며 실제 조건에서 사용자 기대에 부합하는지 확인하려면 이러한 방법론을 결합해야 합니다.
좋은 테스트 작성하기
효과적인 테스트를 작성하는 일은 안정적이고 안전한 Solana 프로그램을 개발하는 데 매우 중요합니다. 좋은 테스트의 핵심은 사용한 프레임워크나 코드 구현 세부 사항이 아니라 테스트하려는 동작에 집중하는 것입니다. 개발자는 테스트 주도 개발(TDD), 준비-실행-검증(AAA) 패턴, 업계 모범 사례의 원칙을 결합해 코드 품질을 높이는 효율적인 테스트 전략을 만들 수 있습니다.
테스트 주도 개발(TDD)
TDD는 테스트 작성을 중심으로 진행하는 견고한 소프트웨어 개발 방식입니다. 즉, 실제 코드를 작성하기 전에 테스트를 먼저 작성하는 것이 핵심입니다. TDD 주기는 일반적으로 세 단계로 이루어집니다.
- 실패하는 테스트 작성: 개발자가 다음에 추가하려는 기능에 대한 테스트를 작성하며 개발을 시작합니다. 테스트 대상 기능이 아직 존재하지 않으므로 이 테스트는 반드시 실패합니다
- 코드 구현: 개발자는 테스트를 통과하는 데 필요한 최소한의 코드만 작성해야 합니다. 여기서는 속도와 단순성이 중요합니다
- 리팩터링: 테스트를 통과하면 동작을 변경하지 않으면서 코드의 구조와 명확성을 개선합니다. 통과한 테스트는 호환성을 깨뜨리는 변경을 방지하는 안전망 역할을 합니다. 중복 코드 제거, 메서드를 더 작은 단위로 분할, 상속 계층 재구성, 이름만으로 의미가 드러나도록 개선하는 작업 등이 포함될 수 있습니다. Solana에서는 특정 트랜잭션이 요청하는 CU 수를 최적화하거나, CPI 수를 줄이거나, 트랜잭션 흐름을 간소화하는 작업이 이에 해당합니다.
좋은 Solana 프로그램을 구축하는 데 TDD가 필수는 아니지만 개발자는 그 철학을 고려해야 합니다. TDD는 프로그램을 세심하게 구축하도록 유도하고, 반복적인 접근 방식으로 유연하고 적응력 있는 개발 프로세스를 조성하며, 프로그램 개발에 필요한 정확성, 보안, 효율성과 완벽하게 부합합니다. TDD는 개발자가 Solana의 고유한 네트워크 및 성능 요구 사항에 맞게 최적화된 더 깔끔하고 집중도 높은 코드를 작성하도록 장려합니다. CU 최적화가 대표적인 예입니다.
준비-실행-검증(AAA) 패턴
AAA 패턴은 명확하고 간결하며 효과적인 테스트를 작성할 수 있는 단순하지만 강력한 구조를 제공합니다. 핵심은 테스트 작성을 세 단계로 명확히 구분해 체계적으로 접근하도록 하는 것입니다.
- 준비: 테스트 환경을 설정하고 관련 입력을 준비합니다. 계정을 생성하거나, 계정 잔액을 시뮬레이션하거나, 인스트럭션을 준비할 수 있습니다. 목표는 테스트할 동작의 조건을 재현하는 통제된 시나리오를 만드는 것입니다
- 실행: 테스트할 동작을 실행합니다. 여기서는 테스트 대상 동작을 유발하는 작업에 집중합니다. 예를 들어 계정 y를 전달해 함수 x를 호출하면 어떤 일이 발생할까요?
- 검증: 작업 결과를 예상 결과와 비교해 평가합니다. 이 단계는 테스트의 성공 또는 실패 여부를 검증하는 데 중요합니다. 예를 들어 검증은 단순한 값 확인부터 여러 상태 변경이 포함된 복잡한 유효성 검사까지 다양합니다. 이러한 검증의 구현 방식은 궁극적으로 사용하는 프레임워크나 프로토콜에 따라 달라집니다. Lighthouse는 트랜잭션에 검증 인스트럭션을 추가해 바람직하지 않은 상태, 위조된 시뮬레이션 결과 또는 과도한 지출 등을 식별할 수 있는 프로그램입니다. 별도 글에서 Lighthouse의 이점과 세부 사항을 더 자세히 살펴보겠습니다.
AAA 패턴의 강점은 적응성에 있으며, 단위 테스트, 통합 테스트, E2E 테스트에 모두 유용합니다. 예를 들면 다음과 같습니다.
- 단위 테스트: 특정 함수 테스트에서는 프로그램 상태를 설정해 준비하고, 함수를 호출해 실행하며, 함수 반환 값이나 그에 따른 상태 변경을 확인해 검증할 수 있습니다
- 통합 테스트: 여러 프로그램 간 상호작용을 테스트할 때는 프로그램을 배포하고 초기 상태를 설정해 준비하고, 관련 트랜잭션을 실행해 동작시키며, 관련된 각 프로그램의 최종 상태를 확인해 검증할 수 있습니다
- E2E 테스트: 프로그램의 E2E 테스트에서는 프로그램 상태를 설정해 준비하고, 예상되는 전체 사용자 흐름(예: 계정 생성, 제안 생성, 제안 투표, 제안의 투표 단계 종료 등)을 진행해 실행하며, 흐름의 결과를 예상 결과와 비교해 검증할 수 있습니다
AAA 패턴은 프로그램 개발에 매우 중요합니다. 프로그램이 의도대로 작동하는지 테스트하는 데 필요한 동작 중심의 접근 방식을 강제합니다. AAA를 중심으로 구성된 테스트는 각 단계가 설정, 실행, 검증으로 명확히 나뉘어 이해하고 유지 관리하기 쉽습니다. 또한 AAA는 특정 동작이나 상호작용에 집중하는 독립적이고 분리된 테스트 작성을 촉진합니다.
업계 모범 사례
좋은 테스트를 작성하는 방법은 Solana 개발에만 국한되지 않습니다. 소프트웨어 개발의 일반적인 교훈을 Solana 프로그램 테스트에 적용할 수 있습니다. 구현 세부 사항에 얽매이지 말고 의도한 동작을 테스트하는 데 집중해야 합니다.
예를 들어 단위 테스트는 일반적으로 메서드의 공개 인터페이스를 대상으로 특정 인수를 제공하고 결과가 예상과 일치하는지 검증해야 합니다. 이 접근 방식을 사용하면 동작이 일관되게 유지되는 한 메서드의 내부 구현이 변경되어도 단위 테스트는 계속 유효합니다. Solana 개발에서는 프로그램의 외부 동작에 영향을 주지 않는 프로그램 로직 변경 때문에 테스트를 리팩터링할 필요가 없어야 한다는 뜻입니다.
단위 테스트를 작성할 때 흔히 빠지는 함정은 테스트 대상 메서드의 내부 작동 방식에 지나치게 의존하는 것입니다. 특정 비공개 메서드가 정해진 횟수만큼 호출되거나 특정 방식으로 코딩되어야 한다고 기대하는 경우가 이에 해당합니다. 이러한 테스트는 너무 취약해 테스트 대상 메서드의 실제 동작이 변하지 않아도 코드 리팩터링만으로 실패하기 쉽습니다. 대신 외부에서 관찰되는 메서드의 결과와 부수 효과에 집중해야 합니다. 코드 커버리지 도구를 사용하면 내부 메커니즘에 과도하게 의존하지 않으면서도 테스트가 포괄적인지 확인할 수 있습니다.
Solana 개발에 이러한 모범 사례를 적용하면 프로그램의 견고성과 적응성이 향상됩니다. 구현 세부 사항보다 동작 테스트에 집중하면 더 탄력적이고 유지 관리하기 쉬운 코드를 만들 수 있습니다. 이는 Solana처럼 역동적인 네트워크 환경에 코드를 배포할 때 필수적입니다. 이 접근 방식을 사용하면 프로그램 로직 변경으로 광범위한 재테스트가 필요해지는 일을 방지하고 프로그램이 배포될 준비를 갖추도록 할 수 있습니다.
이제 테스트를 직접 작성해 보겠습니다.
기본 단위 테스트 작성하기
Rust의 단위 테스트
Rust는 단위 테스트에 독특한 방식을 적용하며, 개발자가 코드와 같은 파일에 테스트를 배치하도록 권장합니다. 이는 tests 모듈을 통해 이루어지며 #[cfg(test)] 속성으로 제어됩니다. test 속성을 사용하면 cargo test 명령으로 소프트웨어를 명시적으로 테스트할 때만 이러한 테스트가 컴파일되고 실행됩니다. 즉, cargo build 명령에서는 실행되지 않습니다. 개발자는 #[ignore] 속성을 사용해 일반 테스트 실행에서 특정 테스트를 제외할 수도 있습니다. 특히 느린 테스트에 유용하며, cargo test -- --ignored 명령으로 명시적으로 호출하면 해당 테스트를 실행할 수 있습니다.
예시로 다음 Rust 함수를 살펴보겠습니다.
pub fn bubble_sort<T: Ord>(array: &mut [T]) {
if array.is_empty() {
return;
}
for i in 0..array.len() {
for j in 0..array.len() - 1 - i {
if array[j] > array[j + 1] {
array.swap(j, j + 1);
}
}
}
}버블 정렬은 목록의 요소를 반복해서 순회하며 현재 요소와 다음 요소를 비교하고, 필요한 경우 값을 서로 바꾸는 정렬 알고리즘입니다. 이 함수가 의도대로 작동하는지 확인하려면 다음과 같은 테스트를 작성할 수 있습니다.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bubble_sort() {
let mut test1 = vec![12, 39, 4, 36, 777];
assert_eq!(bubble_sort(&mut test1), vec![4, 12, 36, 39, 777]);
let mut test2 = vec![21, 55, 14, -123, 32, 0];
assert_eq!(bubble_sort(&mut test2), vec![-123, 0, 14, 21, 32, 55]);
let mut test3 = vec!["Orange", "Pear", "Apple", "Grape", "Banana"];
assert_eq!(bubble_sort(&mut test3), vec!["Apple", "Banana", "Grape", "Orange", "Pear"]);
}
}이 예제에서는 tests 모듈에 #[cfg(test)] 속성이 지정된 방식을 보여줍니다. 모듈 안에서는 use super::*;를 사용해 상위 모듈의 모든 공개 항목을 현재 테스트 모듈의 범위로 가져옵니다. 그런 다음 정렬된 벡터의 예상 형태를 검증하는 여러 테스트 사례가 있습니다. Rust는 일반적인 참 여부를 확인하는 assert!, 동등성을 확인하는 assert_eq!, 비동등성을 확인하는 assert_ne! 등 여러 검증 매크로를 제공합니다. 이러한 검증은 Rust 테스트 전략의 핵심이며, 테스트 작성을 시작하는 데 필요한 전부라고 할 수 있습니다.
아주 기본적인 예로, 계정에 특정 트랜잭션 비용을 지불할 충분한 잔액이 있는지 판단하는 함수가 있다고 가정해 보겠습니다.
pub fn has_sufficient_balance(account_balance: u64, transaction_fee: u64) -> bool {
account_balance >= transaction_fee
}이 함수는 특정 계정의 현재 잔액과 예상 트랜잭션 수수료라는 두 인수를 받습니다. 계정 잔액이 트랜잭션 수수료를 충당하기에 충분하면 true를 반환하고, 그렇지 않으면 false를 반환합니다. 다음 단위 테스트로 간단히 테스트할 수 있습니다.
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sufficient_funds_for_transaction() {
let account_balance = 1_000_000;
let transaction_fee = 5_000;
assert!(has_sufficient_balance(account_balance, transaction_fee));
}
#[test]
fn insufficient_funds_for_transaction() {
let account_balance = 1_000;
let transaction_fee = 5_000;
assert!(!has_sufficient_balance(account_balance, transaction_fee));
}
}첫 번째 테스트에서는 계정 잔액이 트랜잭션 수수료보다 훨씬 많을 때 has_sufficient_balance가 true를 반환하는지 검증합니다. 이는 트랜잭션 비용을 충당할 자금이 충분하다는 뜻입니다. 두 번째 테스트에서는 계정 잔액이 트랜잭션 수수료보다 적을 때 has_sufficient_funds가 false를 반환하는지 검증합니다. 이는 트랜잭션 비용을 충당할 자금이 부족하다는 뜻입니다.
Rust 테스트 시 알아야 할 기타 중요 사항
Rust에는 특정 조건에서 패닉이 발생할 것으로 예상되는 테스트를 표시하는 #[should_panic] 속성이 있습니다. 오류 처리 경로를 테스트하고 예상 패닉 메시지를 지정하는 데 유용합니다.
#[test]
#[should_panic(expected = "Divide-by-zero error")]
fn test_divide_by_zero() {
divide_non_zero_result(0, 0);
}다른 많은 언어와 달리 Rust에서는 비공개 함수를 직접 테스트할 수 있습니다. 코드 기능의 모든 측면을 단위 테스트로 다룰 수 있어 더 세밀한 단위 테스트가 가능합니다.
Rust는 더 고급 테스트 구성 기법도 지원합니다.
- 중첩 모듈: 복잡한 프로젝트에서는 테스트를 중첩 모듈로 구성해 프로젝트 구조를 반영하는 명확한 계층 구조를 만들 수 있습니다
- 결과 기반 테스트: Rust에서는 테스트가
Result<(), E>타입을 반환할 수 있습니다. 이를 통해 개발자는 테스트 안에서?연산자를 사용해 오류를 더 표현력 있게 처리할 수 있습니다
Mocha와 Chai를 사용한 TypeScript 단위 테스트
Anchor가 Solana의 Rust 개발에서 사실상 공용어로 자리 잡으면서 TypeScript는 프로그램 테스트에 널리 사용되는 선택지가 되었습니다. anchor init 명령을 사용하면 새로운 Anchor 프로젝트에 Mocha 테스트 프레임워크와 Chai 검증 라이브러리가 기본으로 초기화됩니다.
Mocha는 Node.js에서 실행되는 기능이 풍부한 JavaScript 테스트 프레임워크입니다. 비동기 테스트를 매우 간단하게 만들어 줍니다. Solana 개발에서 Mocha는 주로 dApp의 클라이언트 측 로직과 기타 블록체인 상호작용을 테스트하는 데 사용됩니다.
Chai는 Mocha와 같은 모든 JavaScript 테스트 프레임워크와 함께 사용할 수 있는 검증 라이브러리입니다. 개발자가 읽기 쉬운 방식으로 검증을 표현할 수 있는 다양한 함수를 제공합니다. Chai의 expect, should, assert 인터페이스를 사용하면 읽고 쓰기 직관적인 포괄적 테스트를 작성할 수 있습니다. expect 및 should 인터페이스에서는 언어 체인, 즉 연결 가능한 getter를 사용해 검증의 가독성을 높입니다. Chai를 사용하면 expect({a: 1, b: 2}).to.not.have.any.keys(“c”, “d”);처럼 가독성이 높고 유효한 검증을 작성할 수 있습니다.
예를 들어 anchor init hello_world 명령으로 hello_world 프로젝트를 생성하면 hello_world/tests 디렉터리에 다음 hello_world.ts 테스트 파일이 생성됩니다.
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { HelloWorld } from "../target/types/hello_world";
describe("hello_world", () => {
// Configure the client to use the local cluster.
anchor.setProvider(anchor.AnchorProvider.env());
const program = anchor.workspace.HelloWorld as Program<HelloWorld>;
it("Is initialized!", async () => {
// Add your test here.
const tx = await program.methods.initialize().rpc();
console.log("Your transaction signature", tx);
});
});각 부분의 의미를 살펴보겠습니다.
Mocha는 describe 블록으로 테스트를 그룹화하고 it 함수로 테스트 사례를 정의합니다. 이 예제는 구조화된 테스트 접근 방식인 AAA 패턴을 따릅니다.
- 준비: 여기서
anchor.setProvider(anchor.AnchorProvider.env());는 Anchor 클라이언트가 환경의 기본 provider를 사용하도록 구성합니다. 일반적으로 로컬 Solana 테스트 검증인을 가리킵니다. 그런 다음programconst 선언은 테스트할 프로그램의 인스턴스를 초기화해 테스트 안에서 메서드를 호출할 수 있도록 합니다 - 실행: 테스트 사례
“Is initialized!”에서 프로그램의initialize메서드를 호출하고 트랜잭션을 전송합니다 - 검증: 이 테스트 사례에서는 검증 없이 트랜잭션 서명을 기록합니다. 일반적으로 이 단계에서 Chai를 사용해 검증합니다. 아주 기본적인 예로 기본 테스트 코드를
expect(tx).to.be.a(“string”);와 같은 검증으로 수정할 수 있습니다. 더 상세한 테스트라면 초기화 후 프로그램 상태를 가져와 검사하고 예상 값과 일치하는지 검증할 수 있습니다
Mocha와 Chai를 Anchor 프로젝트에 기본으로 구성되는 AAA 패턴과 함께 사용하면 프로그램 테스트를 위한 견고한 프레임워크를 만들 수 있습니다. Solana 개발자는 테스트 환경을 명확하게 준비하고, 프로그램 메서드를 호출해 실행하며, 결과를 검증함으로써 프로그램이 예측 가능하고 안정적으로 작동하도록 보장할 수 있습니다.
예를 들어 사용자가 Solana에서 볼트에 SOL을 입금하고 출금할 수 있는 프로그램을 개발한다고 가정해 보겠습니다. 입금 기능이 예상대로 작동하는지 확인하기 위해 Mocha와 Chai를 사용해 TypeScript로 작성한 테스트는 다음과 같습니다.
import { expect } from "chai";
import { PublicKey } from "@solana/web3.js";
import { depositSOL } from "../src/vault";
describe("Vault Program", function() {
describe("Deposit functionality", function() {
it("should correctly deposit SOL into the vault", async function() {
const vaultPublicKey = new PublicKey(/* vault public key */);
const userPublicKey = new PublicKey(/* user public key */);
const depositAmount = 1; // 1 SOL
const initialVaultBalance = await getVaultBalance(vaultPublicKey);
await depositSOL(vaultPublicKey, userPublicKey, depositAmount);
const finalVaultBalance = await getVaultBalance(vaultPublicKey);
expect(finalVaultBalance).to.equal(initialVaultBalance + depositAmount);
});
});
});이 예제는 SOL을 볼트에 입금하는 가상의 depositSOL 함수를 테스트합니다. 입금 후 볼트 잔액이 정확한 금액만큼 증가하는지 검증합니다. 볼트의 현재 잔액을 가져오는 유틸리티 함수로 가정한 getVaultBalance 함수를 사용합니다.
Mocha와 Chai로 TypeScript를 테스트할 때 알아야 할 기타 중요 사항
TypeScript의 정적 타입 시스템은 특히 복잡하거나 명확하게 정의되지 않은 타입을 다룰 때 테스트 작성을 다소 까다롭게 만들 수 있습니다. 테스트에서 타입 관련 문제를 피하려면 타입 단언을 사용하세요. 다만 잘못된 타입으로 발생할 수 있는 잠재적 런타임 오류가 이러한 단언에 가려지지 않도록 주의해야 합니다.
TypeScript에서 객체나 함수를 모킹할 때는 모킹된 항목이 올바른 타입을 따르는지 확인하세요. ts-sinon이나 ts-mockito 같은 라이브러리를 사용하면 타입 안전한 mock을 생성할 수 있어 테스트의 정확성을 유지하고 프로그램의 실제 동작을 반영할 수 있습니다.
Mocha는 특정 테스트만 실행하거나 건너뛸 수 있는 only 및 skip 메서드를 제공합니다. 개발 중에는 편리하지만 이를 실수로 프로덕션에 커밋해 테스트가 불완전하게 실행될 수 있습니다. 테스트를 프로덕션에 푸시하기 전에 항상 only 또는 skip가 있는지 검토하세요. 또한 비동기 코드에서 Mocha의 hook, 즉 beforeEach, afterEach, before, after를 사용할 때는 주의해야 합니다. async/await를 사용해 promise를 올바르게 처리하거나 done 콜백 메서드를 호출해 해결되지 않은 promise나 호출되지 않은 콜백이 발생하지 않도록 하세요.
Chai의 expect().to.deep.equal()를 사용할 때는 날짜나 무작위 값처럼 동적으로 생성되는 속성을 포함한 객체에서 어떻게 동작하는지 유의하세요. 이러한 속성으로 인해 깊은 동등성을 기대하는 테스트가 예기치 않게 실패할 수 있습니다. 가능한 경우 더 구체적인 검증을 위해 Chai의 expect().to.include()를 사용해 보세요.
인기 있는 Solana 테스트 프레임워크
Bankrun
뱅크는 클라이언트 계정 추적, 프로그램 실행 관리, Solana 원장의 무결성과 진행 상태 유지를 담당합니다. 본질적으로 특정 시점의 원장 스냅샷이며, 특정 블록의 트랜잭션으로 생성된 상태를 담고 있습니다.
Bankrun은 Solana 프로그램을 위해 Node.js로 작성된 가볍고 유연한 테스트 프레임워크입니다. 사용 편의성과 속도에 중점을 두어 개발자가 프로그램 테스트를 빠르게 작성하고 실행할 수 있습니다. Bankrun의 진정한 가치는 개발자가 통제되고 효율적인 환경에서 Solana 뱅크를 시뮬레이션하고 상호작용할 수 있게 해주는 테스트 프레임워크라는 점입니다. Bankrun은 이러한 환경을 설정할 때 일반적으로 발생하는 오버헤드 없이 Solana 뱅크의 동작을 재현하여 테스트 프로세스를 간소화합니다.
Bankrun은 RPC 노드의 동작을 모방하면서도 성능과 유연성을 크게 높인 경량 BanksServer를 기반으로 설계되었습니다. 개발자는 BanksClient를 통해 이 서버와 상호작용할 수 있습니다. 이 클라이언트는 계정 잔액과 트랜잭션 상태를 조회하고 트랜잭션을 시뮬레이션하는 메서드를 포함한 종합 도구 세트를 제공합니다. 특히 tryProcessTransaction 메서드를 사용하면 실패할 것으로 예상되는 트랜잭션도 JavaScript 오류를 발생시키지 않고 처리할 수 있습니다. 따라서 개발자는 특정 실패 모드나 로그 메시지를 직접 검증할 수 있습니다.
Meta-DAO의 Futarchy GitHub 저장소는 Bankrun으로 프로덕션용 코드를 테스트하는 훌륭한 예시입니다.
Anchor와 통합하기
Bankrun을 Anchor와 통합하는 과정은 매우 간단합니다. 개발자는 startAnchor를 사용해 Anchor 워크스페이스의 모든 프로그램을 테스트 환경에 자동으로 배포할 수 있습니다. 이를 통해 완전한 Solana 환경에서 프로그램의 동작을 정확하게 재현하여 테스트할 수 있습니다. Bankrun 문서는 다음 코드 예시를 제공합니다.
import { startAnchor } from "solana-bankrun";
import { PublicKey } from "@solana/web3.js";
test("anchor", async () => {
const context = await startAnchor("tests/anchor-example", [], []);
const programId = new PublicKey(
"Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS",
);
const executableAccount = await context.banksClient.getAccount(programId);
expect(executableAccount).not.toBeNull();
expect(executableAccount?.executable).toBe(true);
});anchor-bankrun 패키지는 BankrunProvider 클래스를 내보내 Anchor와 Bankrun을 연동하는 강력한 확장 기능입니다. 테스트 중 AnchorProvider를 그대로 대체해 사용할 수 있습니다.
Bankrun으로 임의의 계정 작성하기
Bankrun의 대표적인 기능은 임의의 계정 데이터를 작성할 수 있다는 점입니다. 이 기능은 계정 상태의 제약을 우회할 수 있게 해주며 전례 없는 수준의 유연성을 제공합니다. 예를 들어 개발자는 USDC 민트 키페어 없이도 상당량의 USDC를 보유한 계정을 시뮬레이션할 수 있습니다. 실제 토큰을 조작할 필요가 없어 복잡한 시나리오의 설정 과정을 간소화하므로 테스트에 매우 유용합니다.
Bankrun 문서는 start 함수를 통해 이 기능을 보여주는 무제한 USDC 민트 코드 예시를 제공합니다. start 함수는 프로그램을 배포하고 지정된 대로 계정 데이터를 설정하여 테스트 환경을 준비합니다.
시간 이동
또 다른 독립적인 기능은 Bankrun의 시간 이동 기능입니다. 즉, 테스트 목적으로 시간 개념을 조작할 수 있습니다. 시간을 조작하면 Solana 클러스터 시계, 즉 Clock sysvar를 빠르게 앞당기거나 되돌려 특정 시간 조건을 즉시 시뮬레이션할 수 있습니다. 이 기능은 베스팅 일정, 토큰 잠금 또는 특정 시점에 도달하면 실행되는 기능처럼 시간 기반 로직으로 작동하는 프로그램을 테스트할 때 매우 중요합니다.
setClock 메서드 덕분에 시간 이동은 간단합니다. 이 메서드를 사용하면 클러스터의 현재 시간을 미리 정의된 Unix 타임스탬프로 설정하여 전체 테스트 환경을 과거나 미래의 특정 시점으로 이동할 수 있습니다. 테스트 내 작업과 트랜잭션은 지정된 시간이 현재인 것처럼 계속되므로 해당 조건에서 프로그램이 어떻게 동작하는지 정확하게 평가할 수 있습니다.
다음은 Bankrun으로 시간 이동을 구현하는 매우 기본적인 예시입니다.
import { start } from "solana-bankrun";
import { PublicKey, Transaction, SystemProgram } from "@solana/web3.js";
async function simulateTimeTravel(context, secondsForward) {
const newTimestamp = context.clock.unixTimestamp + secondsForward;
context.adjustClock(newTimestamp);
}
test("One Year Later...", async () => {
const context = await start([], []);
const { banksClient, payer } = context;
// Simulate setting the cluster clock forward by one year (in seconds)
const oneYearInSeconds = 365 * 24 * 60 * 60;
await simulateTimeTravel(context, oneYearInSeconds);
// Proceed with tests assuming the future time
const transaction = new Transaction().add(
SystemProgram.transfer({
fromPubkey: payer.publicKey,
toPubkey: PublicKey.unique(),
lamports: 100,
}),
);
transaction.recentBlockhash = context.lastBlockhash;
transaction.sign(payer);
await banksClient.processTransaction(transaction);
// Add assertions here to test expected future behavior
});Bankrun vs. solana-test-validator
Bankrun과 solana-test-validator 중 무엇을 선택할지는 테스트 시나리오의 구체적인 요구 사항에 따라 크게 달라집니다. Bankrun은 속도와 유연성이 뛰어나고 전문 기능을 제공하므로 대부분의 개발 시나리오, 특히 빠른 반복 작업이나 정밀한 시뮬레이션이 필요한 경우에 선호됩니다. 다만 실제 검증인의 동작이나 BanksServer에서 지원하지 않는 RPC 메서드에 의존하는 테스트에는 solana-test-validator가 여전히 유용합니다.
solana-program-test
solana-program-test 크레이트는 Solana 프로그램을 위해 명시적으로 설계된 Rust 기반 테스트 프레임워크를 제공합니다. 이 프레임워크의 중심에는 BanksClient가 있습니다. Bankrun과 마찬가지로 Solana 뱅크의 작업을 시뮬레이션하므로 개발자는 메인넷과 유사한 테스트 조건에서 프로그램을 배포하고 상호작용하며 동작을 평가할 수 있습니다. ProgramTest 구조체는 BanksClient를 보완하며 테스트 환경을 초기화하는 유틸리티입니다. 즉, 지정된 프로그램을 개발하고 필요한 계정을 설정하는 데 도움을 줍니다. BanksTransactionResultWithMetadata, InvokeContext, ProgramTestContext 같은 추가 구조체는 테스트 중 처리된 트랜잭션에 대한 풍부한 정보와 컨텍스트를 제공하여 전반적인 디버깅 및 검증 과정을 개선합니다.
로컬 개발과 테스트를 간소화하기 위해 solana-program-test는 여러 프로그램을 자동으로 미리 로드합니다.
- SPL Token 및 2022 버전
- SPL Memo 버전 1.0 및 3.0
- SPL Associated Token Account
이러한 공통 프로그램을 수동으로 설정할 필요가 없으므로 더 빠르고 집중도 높은 테스트 환경을 구성할 수 있습니다.
Marginfi GitHub 저장소에는 프로덕션용 코드에 solana-program-test를 구현한 훌륭한 예시가 여러 개 있습니다. Bonfida의 개발 가이드에서도 solana-program-test 프레임워크를 사용해 통합 테스트를 작성하는 과정을 자세히 설명합니다.
solana-test-framework
solana-test-framework는 Halborn이 개발한 solana-program-test의 확장 기능입니다. 여러 편의 메서드로 BanksClient, RpcClient, ProgramTest, ProgramTestContext를 확장하여 테스트 환경을 강화하도록 설계되었습니다. 예를 들어 Bankrun과 마찬가지로 ProgramTestContext 확장 기능을 사용하면 개발자가 특정 타임스탬프로 이동하고 오라클 가격을 업데이트하는 고급 테스트 시나리오를 구현할 수 있습니다.
이러한 확장 기능은 다음과 같은 개선 사항을 제공합니다.
- 트랜잭션 관리:
transaction_from_instructions를 통해 트랜잭션 조립, 서명, 결제를 간소화합니다 - 계정 역직렬화:
get_account_with_anchor and get_account_with_borsh를 사용해 Anchor 및 Borsh 계정을 각각 손쉽게 조회하고 역직렬화할 수 있습니다 - 계정 생성 및 프로그램 배포:
create_account,create_token_mint,create_token_account,deploy_program같은 함수로 테스트 환경을 효율적으로 설정할 수 있습니다.
solana-test-framework 는 외부 클러스터와 시뮬레이션된 런타임을 모두 지원합니다. Solana 1.9부터 1.14까지의 버전과 1.9, 1.10, 1.14에 해당하는 Anchor 버전을 포함해 여러 Solana 및 Anchor 버전과 호환됩니다.
테스트 시나리오 예시
프로그램
다음 프로그램을 예로 살펴보겠습니다.
use anchor_lang::prelude::*;
use anchor_lang::solana_program::system_instruction;
use solana_program::program::invoke;
declare_id!("3vMZa7r3CpHGejvXYbUpPXmm54FxCDPF1QAYnnzL88J9");
#[program]
pub mod king_of_the_hill {
use super::*;
pub fn initialize(ctx: Context<Initialize>, initial_prize: u64) -> Result<()> {
// In case the person who went first didn't send any SOL as the initial prize
require!(initial_prize > 0, ErrorCode::NeedAnInitialPrize);
let game_state = &mut ctx.accounts.game_state;
game_state.king = ctx.accounts.initial_king.key();
game_state.prize = initial_prize;
let transfer_instruction = system_instruction::transfer(
&ctx.accounts.initial_king.key(),
&ctx.accounts.prize_pool.key(),
initial_prize,
);
invoke(
&transfer_instruction,
&[
ctx.accounts.initial_king.to_account_info(),
ctx.accounts.prize_pool.to_account_info(),
ctx.accounts.system_program.to_account_info(),
],
)?;
Ok(())
}
pub fn become_king(ctx: Context<BecomeKing>, new_prize: u64) -> Result<()> {
require!(
new_prize > ctx.accounts.game_state.prize,
ErrorCode::BidTooLow
);
let transfer_to_pool_instruction = system_instruction::transfer(
&ctx.accounts.payer.key(),
&ctx.accounts.prize_pool.key(),
new_prize,
);
// Send the new king's funds to the pool
invoke(
&transfer_to_pool_instruction,
&[
ctx.accounts.payer.to_account_info(),
ctx.accounts.prize_pool.to_account_info(),
ctx.accounts.system_program.to_account_info(),
],
)?;
// Send the old king's funds back
ctx.accounts.prize_pool.sub_lamports(ctx.accounts.game_state.prize);
ctx.accounts.king.add_lamports(ctx.accounts.game_state.prize);
ctx.accounts.game_state.king = ctx.accounts.payer.key();
ctx.accounts.game_state.prize = new_prize;
Ok(())
}
}
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(
init,
payer = initial_king,
space = 8 + 32 + 8 + 1,
seeds = [b"game_state"],
bump,
)]
pub game_state: Account<'info, GameState>,
#[account(mut)]
pub initial_king: Signer<'info>,
#[account(
init,
payer = initial_king,
space = 8 + 8,
seeds = [b"prize_pool"],
bump,
)]
/// CHECK: This is okay - it's a PDA to store SOL and doesn't need a data layout
pub prize_pool: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
}
#[derive(Accounts)]
pub struct BecomeKing<'info> {
#[account(
mut,
has_one = king,
)]
pub game_state: Account<'info, GameState>,
#[account(mut)]
/// CHECK: This is okay - it's only receiving SOL and we don't need any other access
pub king: UncheckedAccount<'info>,
#[account(mut)]
pub payer: Signer<'info>,
#[account(
mut,
seeds = [b"prize_pool"],
bump,
)]
/// CHECK: This is okay - it's a PDA to store SOL and doesn't need a data layout
pub prize_pool: UncheckedAccount<'info>,
pub system_program: Program<'info, System>,
}
#[account]
pub struct GameState {
pub king: Pubkey,
pub prize: u64,
pub prize_pool_bump: u8,
}
#[error_code]
pub enum ErrorCode {
#[msg("The initial prize must be greater than zero")]
NeedAnInitialPrize,
#[msg("The bid must be higher than the current prize")]
BidTooLow,
#[msg("Invalid prize pool account")]
InvalidPrizePoolAccount,
}이 프로그램은 Solana에서 간단한 “King of the Hill” 게임을 구현합니다. 사용자는 현재 왕이 보낸 것보다 더 많은 SOL을 상금 풀에 보내 “왕”이 될 수 있습니다. 새로운 왕이 자리를 차지하면 이전 왕이 보낸 SOL은 이전 왕에게 반환됩니다.
프로그램의 기능은 다음과 같습니다.
- 초기화: 초기 왕, 즉 게임을 최초로 초기화한 플레이어와 초기 상금으로 게임을 설정합니다. 초기 상금은 0보다 커야 합니다. 그런 다음 초기 왕의 초기 상금을 상금 풀로 전송합니다
- 왕 되기: 새로운 플레이어가 현재 상금보다 많은 SOL을 입찰하여 왕이 될 수 있습니다. 현재 상금을 물러나는 왕에게 전송하고 상금 풀을 새 왕의 입찰액으로 업데이트하여 해당 플레이어를 새로운 왕으로 만듭니다. 입찰액은 현재 상금보다 커야 합니다
테스트 작성하기
다음 코드로 King of the Hill 게임을 성공적으로 테스트할 수 있습니다.
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { KingOfTheHill } from "../target/types/king_of_the_hill";
import { assert } from "chai";
const web3 = require("@solana/web3.js");
describe("King of the Hill Tests", () => {
// Configure the client to use the local cluster.
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.KingOfTheHill
as Program<KingOfTheHill>;
let initialKing, newKing;
let gameStatePDA, prizePoolPDA;
// Utility function for airdrops
async function fundWallet(account, amount) {
const publicKey = account.publicKey ? account.publicKey : account;
await provider.connection.confirmTransaction(
await provider.connection.requestAirdrop(publicKey, amount),
"confirmed"
);
}
before(async () => {
initialKing = web3.Keypair.generate();
newKing = web3.Keypair.generate();
await fundWallet(initialKing, 25 * web3.LAMPORTS_PER_SOL);
await fundWallet(newKing, 30 * web3.LAMPORTS_PER_SOL);
[gameStatePDA] = web3.PublicKey.findProgramAddressSync(
[Buffer.from("game_state")],
program.programId
);
[prizePoolPDA] = web3.PublicKey.findProgramAddressSync(
[Buffer.from("prize_pool")],
program.programId
);
});
it("Initializes the game correctly", async () => {
// Arrange
await fundWallet(gameStatePDA, 1 * web3.LAMPORTS_PER_SOL);
await fundWallet(prizePoolPDA, 1 * web3.LAMPORTS_PER_SOL);
let initialPrize = new anchor.BN(1 * web3.LAMPORTS_PER_SOL);
// Act
const tx = await program.methods
.initialize(initialPrize)
.accounts({
gameState: gameStatePDA,
initialKing: initialKing.publicKey,
prizePool: prizePoolPDA,
systemProgram: web3.SystemProgram.programId,
})
.signers([initialKing])
.rpc();
// Assert
let gameState: any = await program.account.gameState.fetch(gameStatePDA);
assert.equal(gameState.king.toBase58(), initialKing.publicKey.toBase58());
assert.equal(
gameState.prize.toString(),
new anchor.BN(1 * web3.LAMPORTS_PER_SOL).toString()
);
});
it("Changes the king correctly", async () => {
// Arrange
const initialKingBalanceBefore = await provider.connection.getBalance(initialKing.publicKey);
let newPrize = new anchor.BN(2 * web3.LAMPORTS_PER_SOL);
// Act
const becomeKingTx = await program.methods.becomeKing(newPrize)
.accounts({
gameState: gameStatePDA,
king: initialKing.publicKey, // Correct usage of current king
payer: newKing.publicKey, // New king who pays and becomes the king
prizePool: prizePoolPDA,
systemProgram: web3.SystemProgram.programId,
})
.signers([newKing]) // Signing by newKing
.rpc();
// Assert
const initialKingBalanceAfter = await provider.connection.getBalance(initialKing.publicKey);
const expectedBalance = initialKingBalanceBefore + new anchor.BN(1 * web3.LAMPORTS_PER_SOL).toNumber();
assert.ok(initialKingBalanceAfter >= expectedBalance, "Old king did not receive the funds back correctly");
// Fetch the updated game state.
const updatedGameState = await program.account.gameState.fetch(gameStatePDA);
// Assertions to confirm the state has updated as expected.
assert.equal(updatedGameState.king.toBase58(), newKing.publicKey.toBase58(), "King should be updated to newKing.");
assert.equal(updatedGameState.prize.toString(), newPrize.toString(), "Prize should be updated to newPrize.");
})
});하나씩 자세히 살펴보겠습니다.
먼저 필요한 항목을 가져오고 Anchor에서 테스트 환경을 설정합니다. 이 예시에서는 localhost에서 Mocha와 Chai를 사용해 TypeScript로 테스트합니다.
import * as anchor from "@coral-xyz/anchor";
import { Program } from "@coral-xyz/anchor";
import { KingOfTheHill } from "../target/types/king_of_the_hill";
import { assert } from "chai";
const web3 = require("@solana/web3.js");describe를 사용해 테스트 케이스를 그룹화합니다. 또한 로컬 클러스터를 사용하도록 클라이언트를 설정하고, 프로그램을 올바르게 지정하고, 초기 왕, 새 왕, 게임 상태 PDA, 상금 풀 PDA를 위한 변수를 각각 초기화하고, 에어드롭을 간편하게 처리할 유틸리티 함수를 생성합니다.
describe("King of the Hill Tests", () => {
// Configure the client to use the local cluster.
const provider = anchor.AnchorProvider.env();
anchor.setProvider(provider);
const program = anchor.workspace.KingOfTheHill as Program<KingOfTheHill>;
let initialKing, newKing;
let gameStatePDA, prizePoolPDA;
// Utility function for airdrops
async function fundWallet(account, amount) {
const publicKey = account.publicKey ? account.publicKey : account;
await provider.connection.confirmTransaction(
await provider.connection.requestAirdrop(publicKey, amount),
"confirmed"
);
}
// Other code
});다음으로 before 훅을 사용해 초기 왕과 새 왕의 키페어를 설정하고 자금을 지급합니다. 게임 상태와 상금 풀의 PDA도 도출합니다. 이 블록은 테스트 케이스보다 먼저 한 번 실행되므로 각 케이스를 AAA 패턴으로 더 명확하고 간단하게 구성할 수 있습니다.
before(async () => {
initialKing = web3.Keypair.generate();
newKing = web3.Keypair.generate();
await fundWallet(initialKing, 25 * web3.LAMPORTS_PER_SOL);
await fundWallet(newKing, 30 * web3.LAMPORTS_PER_SOL);
[gameStatePDA] = web3.PublicKey.findProgramAddressSync(
[Buffer.from("game_state")],
program.programId
);
[prizePoolPDA] = web3.PublicKey.findProgramAddressSync(
[Buffer.from("prize_pool")],
program.programId
);
});첫 번째 테스트 케이스는 매우 간단합니다. 게임이 올바르게 초기화되는지 확인합니다. 먼저 나중에 상호작용할 수 있도록 게임 상태 및 상금 풀 PDA에 자금을 지급하고 초기 상금을 1 SOL로 설정합니다. 그런 다음 initialPrize를 사용해 initialize 메서드를 호출합니다. 계정으로는 게임 상태 PDA, 초기 왕, 상금 풀 PDA, 시스템 프로그램을 전달합니다. 이 작업의 서명자는 초기 왕입니다. 이후 게임 상태에서 왕과 상금이 올바르게 업데이트되었는지 검증합니다.
it("Initializes the game correctly", async () => {
// Arrange
await fundWallet(gameStatePDA, 1 * web3.LAMPORTS_PER_SOL);
await fundWallet(prizePoolPDA, 1 * web3.LAMPORTS_PER_SOL);
let initialPrize = new anchor.BN(1 * web3.LAMPORTS_PER_SOL);
// Act
const tx = await program.methods
.initialize(initialPrize)
.accounts({
gameState: gameStatePDA,
initialKing: initialKing.publicKey,
prizePool: prizePoolPDA,
systemProgram: web3.SystemProgram.programId,
})
.signers([initialKing])
.rpc();
// Assert
let gameState: any = await program.account.gameState.fetch(gameStatePDA);
assert.equal(gameState.king.toBase58(), initialKing.publicKey.toBase58());
assert.equal(
gameState.prize.toString(),
new anchor.BN(1 * web3.LAMPORTS_PER_SOL).toString()
);
});다음 테스트 케이스는 다른 사용자가 왕이 될 수 있는지 확인합니다. 먼저 왕의 초기 잔액을 가져오고 새 상금을 2 SOL로 설정합니다. 그런 다음 becomeKing 함수를 호출하고 최신 상금 금액을 전달합니다. 계정으로는 게임 상태 PDA, 현재 왕의 공개 키, 지불자인 새 왕, 상금 풀 PDA, 시스템 프로그램을 전달합니다. 새 왕은 서명자로 설정합니다. 왕이 되기 위해 제공했던 초기 SOL을 기존 왕이 돌려받았는지, 그리고 게임 상태가 올바르게 업데이트되었는지 확인하여 검증합니다.
it("Changes the king correctly", async () => {
// Arrange
const initialKingBalanceBefore = await provider.connection.getBalance(initialKing.publicKey);
let newPrize = new anchor.BN(2 * web3.LAMPORTS_PER_SOL);
// Act
const becomeKingTx = await program.methods.becomeKing(newPrize)
.accounts({
gameState: gameStatePDA,
king: initialKing.publicKey, // Correct usage of current king
payer: newKing.publicKey, // New king who pays and becomes the king
prizePool: prizePoolPDA,
systemProgram: web3.SystemProgram.programId,
})
.signers([newKing]) // Signing by newKing
.rpc();
// Assert
const initialKingBalanceAfter = await provider.connection.getBalance(initialKing.publicKey);
const expectedBalance = initialKingBalanceBefore + new anchor.BN(1 * web3.LAMPORTS_PER_SOL).toNumber();
assert.ok(initialKingBalanceAfter >= expectedBalance, "Old king did not receive the funds back correctly");
// Fetch the updated game state.
const updatedGameState = await program.account.gameState.fetch(gameStatePDA);
// Assertions to confirm the state has updated as expected.
assert.equal(updatedGameState.king.toBase58(), newKing.publicKey.toBase58(), "King should be updated to newKing.");
assert.equal(updatedGameState.prize.toString(), newPrize.toString(), "Prize should be updated to newPrize.");
})이 테스트 시나리오에서는 King of the Hill 프로그램의 핵심 기능을 살펴봤습니다. 단위 테스트를 통해 프로그램 로직의 무결성을 세부적인 수준에서 검증했습니다. 왕이 올바르게 변경되는지 테스트하여 시뮬레이션된 실제 환경에서도 프로그램이 의도대로 작동하는지 추가로 확인했습니다. 이러한 테스트는 King of the Hill 프로그램의 품질과 기능을 보장하기 위해 종합적인 테스트 전략이 얼마나 중요한지 보여줍니다.
결론
테스트는 안전하고 안정적이며 효율적인 Solana 프로그램 개발의 초석입니다. 이 글에서는 프로그램 개발 수명 주기의 모든 영역을 다루기 위해 단위, 통합, E2E 테스트를 결합하는 것이 왜 중요한지 살펴봤습니다. 이러한 방법론을 통합하고 Bankrun, solana-program-test, solana-test-framework 같은 강력한 테스트 프레임워크를 활용하면 Solana 프로그램의 품질을 크게 개선할 수 있습니다. Solana 개발 여정을 이어가는 동안 이 글에서 살펴본 원칙, 사례, 예시를 바탕으로 견고하고 효율적이며 안전한 프로그램을 만들어 보세요.
여기까지 읽어주셔서 감사합니다! 아래에 이메일 주소를 입력하고 Solana의 새로운 소식을 빠짐없이 받아보세요. 더 자세히 알아볼 준비가 되셨나요? 지금 Helius 블로그의 최신 글을 살펴보고 Solana 여정을 계속하세요.
추가 자료
관련 아티클
Helius 구독하기
최신 Solana 개발 소식을 확인하고 새 게시물 알림을 받아보세요


