Rust 공유 코어 7편: 테스트와 CI를 층별로 나누기

공통 규칙을 Rust에서 검사하고 세 클라이언트가 그 결과를 쓰는 구조의 목표는 코드를 줄이는 것만이 아니에요. 결과가 같아야 해요. 그런데 Rust 테스트가 통과한 뒤에도 깨질 수 있는 곳이 플랫폼마다 있어요.

  • iOS: Rust 테스트는 통과했는데 링크에서 오류가 나요.
  • Android: 디버그 빌드는 되는데 릴리스 AAB에서 ABI가 빠져요.
  • Web: 개발 서버에서는 되는데 배포 빌드에서 wasm 로딩이 실패해요.

위 셋은 일어날 수 있는 경우를 든 것이고, 실제로 겪은 사례의 기록은 아니에요. 이번 편은 이런 실패를 어느 층의 검사가 잡아야 하는지를 나눠요.

도메인 검사는 Rust에 모아요

규칙에 대한 검사는 Rust 엔진에 집중하기를 권해요. 스도쿠라면 퍼즐 생성, 풀이의 정확성, 잘못된 수 입력, 메모 토글 reducer, 일별 seed의 재현성, 난이도 분류, 직렬화 왕복, 경곗값, 오류 변환이 대상이에요. 모바일의 가상 장치 없이 cargo test로 실행할 수 있게 구성하기를 권해요.

같은 seed와 같은 액션 나열에 대해 결과를 고정해 두는 것이 좋아요. 결과를 달라지게 만드는 후보는 시각, 언어권, 난수의 출처, 시간대가 암묵적으로 끼어드는 경우예요. 그래서 입력으로 받기를 제안해요. 현재 시각을 읽지 않고 timestamp를 매개변수로 받고, 난수에는 seed를 지정하고, locale code는 바깥에서 받아요.

공통 fixture와 층별 책임

입력과 기댓값을 담은 JSON fixture를 계약으로 쓰기를 제안해요. 둘 수 있는 파일의 예예요.

fixtures/
  start-game-easy-seed-001.json
  apply-action-set-value-valid.json
  apply-action-set-value-conflict.json
  daily-puzzle-2026-06-24.json
  completed-game-score.json

위 목록은 파일 이름의 예예요. 이 가운데 둘은 예제에서 실제로 만들었고, 그 내용은 «예제에서 두 쪽이 읽은 fixture» 절에 있어요. 이 설계에서는 Rust 테스트와 각 플랫폼의 테스트가 같은 fixture 파일을 직접 읽어요. 예제에서 실제로 읽게 해 본 것은 Rust와 Node 쪽이에요. fixture가 통과한다는 것은 그 입력 범위를 확인했다는 뜻이에요.

층마다 검사할 것이 달라요.

Rust test
  -> 도메인 규칙이 맞는가

iOS test
  -> Swift adapter가 Rust 결과를 맞게 해석하는가

Android test
  -> Kotlin adapter와 native library 연결이 맞는가

Web test
  -> wasm package와 TypeScript wrapper가 맞는가

iOS는 Swift가 DTO를 해석하는 부분을, Android는 Kotlin의 변환과 직렬화 설정과 네이티브 연결을, Web은 TypeScript가 wasm의 반환값을 바꾸는 부분과 패키지 연결을 검사해요. 클라이언트마다 엔진의 알고리즘 전체를 다시 검사할 필요는 없다고 봐요. 이 구분은 UniFFI나 다른 도구의 문서가 정해 주는 것이 아니고 제 해석이에요.

예제에서 두 쪽이 읽은 fixture

작은 예제에서 fixture 두 개를 만들고 Rust 테스트와 Web 쪽 테스트가 같은 파일을 읽게 해 봤어요. 실제 앱이 아니고 구조를 확인하려는 최소 workspace예요. 쓴 도구는 cargo 1.96.0, wasm-pack 0.15.0, Node 24.14.1이고, 날짜는 2026년 10월 4일이에요. 충돌이 나는 입력의 fixture는 이렇게 생겼어요.

{
  "request": { "seed": 0 },
  "action": { "type": "set_value", "row": 0, "col": 2, "value": 5 },
  "expected": { "ok": false, "cell_index": 2, "cell": "0", "error_code": "conflict" }
}

Rust 쪽 테스트는 디렉터리의 파일을 모두 읽어 결과를 기대값과 대조해요. 파일 수도 함께 확인해서, fixture가 빠지면 테스트가 실패해요. fixtures.rs에서 import와 나머지 테스트를 뺀 부분이에요.

const ENV: &str = r#"{"locale":"ko-KR"}"#;

// 저장소의 fixtures/ 를 그대로 읽는다. Web 쪽 테스트도 같은 파일을 읽는다.
#[test]
fn fixtures_match_expected() {
    let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures");
    let mut checked = 0;
    for entry in fs::read_dir(dir).unwrap() {
        let path = entry.unwrap().path();
        let fixture: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
        let snapshot = sudoku_core::start_game_json(&fixture["request"].to_string());
        let result: Value =
            serde_json::from_str(&sudoku_core::apply_action_json(&snapshot, &fixture["action"].to_string(), ENV)).unwrap();
        let expected = &fixture["expected"];
        assert_eq!(result["ok"], expected["ok"], "{path:?}");
        assert_eq!(result["error"]["code"], expected["error_code"], "{path:?}");
        if result["ok"] == true {
            let next: Value = serde_json::from_str(result["snapshot"].as_str().unwrap()).unwrap();
            let index = expected["cell_index"].as_u64().unwrap() as usize;
            assert_eq!(&next["cells"].as_str().unwrap()[index..index + 1], expected["cell"].as_str().unwrap(), "{path:?}");
        }
        checked += 1;
    }
    assert_eq!(checked, 2, "fixture 수가 달라졌다");
}

Web 쪽은 wasm-pack으로 만든 패키지를 Node에서 불러서 같은 디렉터리를 읽어요.

// Rust 테스트와 같은 fixtures/ 를 읽어 wasm 패키지의 결과를 대조한다.
import { readdirSync, readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import assert from 'node:assert/strict';
import test from 'node:test';

const wasm = createRequire(import.meta.url)('./wasm-node/sudoku_wasm.js');
const dir = new URL('../fixtures/', import.meta.url);

for (const name of readdirSync(dir)) {
  test(name, () => {
    const fixture = JSON.parse(readFileSync(new URL(name, dir), 'utf8'));
    const snapshot = wasm.start_game(JSON.stringify(fixture.request));
    const result = JSON.parse(wasm.apply_action(snapshot, JSON.stringify(fixture.action), '{"locale":"ko-KR"}'));
    assert.equal(result.ok, fixture.expected.ok);
    assert.equal(result.error?.code ?? null, fixture.expected.error_code);
    if (result.ok) assert.equal(JSON.parse(result.snapshot).cells[fixture.expected.cell_index], fixture.expected.cell);
  });
}

두 쪽의 실행 결과예요. 각 실행의 출력에서 테스트 결과 부분만 옮겼어요. 위가 cargo test -p sudoku-core, 아래가 node --test예요.

running 5 tests
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

✔ apply-action-set-value-conflict.json (2.652125ms)
✔ apply-action-set-value-valid.json (0.324083ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0

Rust 테스트 다섯 개 가운데 하나가 fixture 테스트예요. iOS와 Android의 adapter 테스트는 만들지 않았어요. Swift와 Kotlin에서 같은 fixture를 읽는 것은 확인하지 못한 부분이에요.

CI는 새 체크아웃에서 산출물을 만들어요

CI의 목표는 새로 체크아웃한 상태에서 산출물이 만들어지는 것을 재현하는 일이에요. 단계는 다섯 묶음으로 나눌 수 있어요.

Rust
  cargo fmt
  cargo clippy
  cargo test

UniFFI
  generate Swift binding
  generate Kotlin binding
  generated output validation

Apple
  build Rust static library
  create XCFramework
  xcodebuild build/test

Android
  build Rust .so per ABI
  generate Kotlin binding
  Gradle unit test
  assemble release or debug
  native library packaging check

Web
  wasm-pack build
  typecheck
  test
  production build

위 블록은 단계의 이름을 늘어놓은 것이고 CI 설정 파일이 아니에요. CI 설정 파일은 만들지 않았어요. 대신 예제에서는 테스트, 바인딩 생성, XCFramework, Android용 .so, Wasm 빌드를 스크립트 하나로 묶었어요. 소스만 새 디렉터리에 복사해서 처음부터 돌렸고 종료 코드 0으로 끝났어요. 한 번 실행한 것이고, CI 서버에서 돌린 것은 아니에요.

오래된 결과물도 잡아야 해요. 생성된 코드와 바이너리가 로컬에 남은 것에 기대고 있지 않은지 경계하고, CI에서 다시 생성한 앞뒤로 diff가 없는지 확인하기를 제안해요.

job을 나누면 실패했을 때 어디부터 볼지를 정하기 쉬워요. job 이름의 예는 rust-core, uniffi-bindings, apple-xcframework, android-native, web-wasm, contract-fixtures예요. 공유 fixture가 실패하면 도메인 계약부터 보고, iOS만 실패하면 Apple 패키지 구성을 먼저 봐요. 플랫폼마다 볼 후보는 이래요.

  • iOS: XCFramework의 slice, Swift 바인딩 불일치, Xcode의 차이, 실기기와 시뮬레이터의 타깃 차이
  • Android: ABI, .so의 이름, NDK, 16 KB 페이지, Gradle의 패키징 설정
  • Web: wasm의 시작 처리, 번들러, SSR과 만나는 지점, TypeScript 선언 불일치

배포 전에 볼 것과 실행 범위

배포하기 전에 확인할 항목은 여섯이에요.

  • 앱에 Rust core의 버전이 반영됐는지
  • Rust API와 UniFFI 생성물이 맞는지
  • XCFramework에 실기기와 시뮬레이터 구성이 들어 있는지
  • Android 릴리스에 필요한 ABI가 있고 16 KB에 대응하는지
  • Web 배포 빌드에서 wasm이 시작되는지
  • 모든 플랫폼에서 fixture의 기댓값이 맞는지

기계로 검사할 수 있는 항목은 CI로 옮기고, 사람이 봐야 하는 것만 배포 목록에 남기기를 권해요.

무엇을 언제 돌릴지는 팀이 기준을 정해야 해요. core를 바꾸는 PR에서 플랫폼 앱의 빌드까지 요구할지, 전체 조합은 nightly나 release branch에서 돌릴지를 정해요. PR마다 전체를 돌리면 비용이 늘 수 있어요. 제가 제안하는 절충은 이래요.

  • core를 바꾸는 PR: Rust 테스트, 바인딩 생성, fixture 검사
  • 플랫폼 통합 PR: 그 플랫폼의 빌드와 테스트
  • release candidate: iOS, Android, Web 전부

이 구성은 권장안이에요. CI에 적용해서 몇 번 성공했는지, 실행에 얼마나 걸리는지, CI 설정 파일은 여기에 없어요. 통과 기록은 예제를 로컬에서 돌린 것뿐이에요. 전체 조합을 돌리는 비용은 그대로 남고, 자동화하기 어려워 사람이 확인할 항목은 배포 목록에 남겨요. cargo test가 통과했다는 것만으로는 adapter, 네이티브 패키지, Web 배포의 상태를 보장할 수 없어요.