# Rust shared core, part 7: splitting tests and CI by layer

> Passing Rust tests still leaves iOS linking, Android release ABIs, and Wasm loading in web builds to break. Test the domain in Rust and build fresh in CI.

- Canonical: https://jaemyeong.com/en/blog/rust-shared-core-07-testing-ci/
- Published: 2026.07.26
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #Rust, #CI-CD, #Testing, #UniFFI, #FFI

The goal of checking the common rules in Rust and having three clients consume the result is not only less code. The results have to match. Yet each platform has places that can still break after the Rust tests pass.

- iOS: the Rust tests pass, and linking fails.
- Android: the debug build works, and an ABI is missing from the release AAB.
- Web: the dev server works, and wasm loading fails in the production build.

These three are cases that can happen, not a record of incidents I went through. This part sorts out which layer's checks should catch such failures.

## Domain checks belong in Rust

I recommend concentrating the checks on the rules in the Rust engine. For Sudoku, that covers puzzle generation, the correctness of the solver, invalid number input, the reducer for toggling notes, the reproducibility of the daily seed, difficulty classification, the serialization round trip, boundary values, and error conversion. I recommend setting them up to run with `cargo test`, with no mobile simulator or emulator.

It is good to pin the result for the same seed and the same sequence of actions. What can make results differ is the time, the locale, the source of randomness, and the time zone slipping in implicitly. So I suggest taking them as input: a timestamp parameter in place of reading the current time, a specified seed for randomness, and a locale code received from outside.

## Shared fixtures and the responsibility of each layer

I suggest using JSON fixtures that hold input and expected values as the contract. These are examples of files that could be there.

```text
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
```

The list above gives example file names. Two of them were actually written in an example, and their content is in the section "Fixtures read from both sides in an example." In this design the Rust tests and the tests on each platform read the same fixture files directly. In the example, the two sides that actually read them are Rust and Node. A passing fixture means that range of input was checked.

Each layer checks something different.

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

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

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

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

In order, the Korean questions read: are the domain rules right, does the Swift adapter interpret the Rust result correctly, is the connection between the Kotlin adapter and the native library right, and do the wasm package and the TypeScript wrapper match.

iOS checks how Swift interprets the DTOs. Android checks the Kotlin conversion, the serialization settings, and the native linking. The web checks how TypeScript converts the values returned by wasm, and the package linking. I do not think each client needs to test the whole algorithm of the engine again. This division is not set by the [UniFFI](https://mozilla.github.io/uniffi-rs/latest/) documentation or that of any other tool. It is my own design choice.

## Fixtures read from both sides in an example

In a small example I wrote two fixtures and had a Rust test and a web-side test read the same files. It is not a real app, only a minimal workspace for checking the structure. The tools were cargo 1.96.0, wasm-pack 0.15.0, and Node 24.14.1, and the date was October 4, 2026. The fixture for an input that conflicts looks like this.

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

The Rust test reads every file in the directory and compares the result with the expected values. It also checks the number of files, so a missing fixture fails the test. This is `fixtures.rs` without its imports and its other tests.

```rust
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 수가 달라졌다");
}
```

The Korean comment says that the test reads the repository's `fixtures/` as is and that the web-side test reads the same files. The message in the last assertion says the number of fixtures has changed.

The web side loads the package built by wasm-pack in Node and reads the same directory.

```js
// 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);
  });
}
```

The comment on the first line says it reads the same `fixtures/` as the Rust test and compares the result of the wasm package.

These are the results from both sides, taking only the test result part of each run's output. `cargo test -p sudoku-core` is above, and `node --test` is below.

```text
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
```

One of the five Rust tests is the fixture test. Adapter tests for iOS and Android were not written. Reading the same fixtures from Swift and Kotlin is a part that was not confirmed.

## CI builds the artifacts from a fresh checkout

The goal of CI is to reproduce building the artifacts from a fresh checkout. The steps can be split into five groups.

```text
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
```

The block above lists the names of steps and is not a CI configuration file. No CI configuration file was written. In the example, the tests, binding generation, the XCFramework, the `.so` files for Android, and the Wasm build are tied together in one script. I copied only the sources to a fresh directory, ran it from the start, and it ended with exit code 0. That was a single run, and not on a CI server.

Stale output has to be caught too. Be wary of relying on generated code and binaries left on a local machine, and I suggest checking that there is no diff before and after regenerating in CI.

Splitting jobs makes it easier to decide where to look first when something fails. Examples of job names are `rust-core`, `uniffi-bindings`, `apple-xcframework`, `android-native`, `web-wasm`, and `contract-fixtures`. If a shared fixture fails, look at the domain contract first, and if only iOS fails, look at the Apple package setup first. The candidates to look at on each platform are these.

- iOS: the slices of the [XCFramework](https://developer.apple.com/documentation/xcode/creating-a-multi-platform-binary-framework-bundle), a mismatch in the Swift bindings, differences in Xcode, and the target difference between a device and the simulator
- Android: the ABI, the name of the `.so`, the NDK, [16 KB pages](https://developer.android.com/guide/practices/page-sizes), and the Gradle packaging settings
- Web: how wasm is started, the bundler, the point where it meets SSR, and a mismatch in the TypeScript declarations

## What to check before release, and how much to run

Six items are checked before a release.

- Whether the app reflects the version of the Rust core
- Whether the Rust API and the UniFFI output match
- Whether the XCFramework contains the device and simulator variants
- Whether the Android release has the required ABIs and supports 16 KB
- Whether wasm starts in the web production build
- Whether the expected values of the fixtures match on every platform

I recommend moving what a machine can check into CI and leaving only what a person has to look at on the release list.

What to run and when is for the team to decide. The team decides whether a PR that changes the core also requires the platform app builds, and whether the full combination runs on a nightly or on the release branch. Running everything on every PR can raise the cost. The compromise I suggest is this.

- A PR that changes the core: Rust tests, binding generation, and fixture checks
- A platform integration PR: the build and tests of that platform
- A release candidate: all of iOS, Android, and the web

This setup is a recommendation. How many times it succeeded after being applied in CI, how long a run takes, and a CI configuration file are not here. The only passing record is the local run of the example. The cost of running the full combination remains, and items that are hard to automate and need a person stay on the release list. A passing `cargo test` alone cannot guarantee the state of the adapters, the native packages, or the web release.
