Skip to main content

rust-unit-tests

Write, improve, and run Rust unit tests in the warp Rust codebase.

소스 정보

저장소
warpdotdev/warp
최근 소스 활동
2026년 9월 15일 18:47
감지된 SKILL.md 언어
영어
스타
65,471
포크
5,644

설치 방법

기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.

소스 파일 검토

설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.

SKILL.md 표시 중

SKILL.md
소스 지침 · 읽기 전용 미리보기
name
rust-unit-tests
description
Write, improve, and run Rust unit tests in the warp Rust codebase.
# Rust Unit Tests in warp ## Scope - This skill focuses on crate-level unit tests. - Favor incremental, well-scoped tests that exercise a single function or behavior per case. - For *how* to structure and run tests, read on. For *whether* a change needs a test and at what level, start with "What to unit test". ## What to unit test Default to a unit test when the logic is deterministic and reachable without booting the app: - Parsing, encoding, escaping, and any pure data transformation. - Domain logic and state machines (block lifecycle, selection, ranges, diffing). - Boundary and edge cases: empty input, single element, off-by-one at range ends, min/max, invalid UTF-8, zero-width and wide grapheme clusters. - Error paths and fallbacks, not just the happy path. - **Bug fixes, where the bug is reachable at this level.** A reproducible bug usually means a case was missing from the suite, so add the failing test first and then fix it. Not every bug is unit-testable — a rendering artifact, a PTY timing race, or a crash that needs a real window will not reduce to one. Cover those at the level where they actually reproduce, and don't reshape the code just to force a unit test. Prefer unit tests for anything that fits: they are fast, deterministic, and point straight at the failure. ## When a unit test is the wrong level Be honest about this codebase. Warp is a terminal emulator with a GPU renderer, PTY and shell integration, and IPC. The common "80% unit tests" heuristic assumes a business-logic-heavy system where units exchange messages and transform data. Large parts of this repo are not that, and forcing them under a unit test usually means mocking away the only thing that could actually break. Escalate to a higher level when any of these hold: - The behavior needs a real PTY, shell, window, or display to exist at all. - What can break is the *wiring* between components — a setting taking effect, a keybinding dispatching, focus moving across panes — rather than logic inside any one of them. - Reproducing it requires real IO, spawned processes, or cross-process timing. - The only way to make it unit-testable is to add a trait and an indirection layer that exist solely for the test. - You find yourself stubbing so much that the test no longer exercises anything real. Where to go instead: - `gui-integration-test` — GUI end-to-end behavior, terminal and shell integration, settings and keybinding wiring. - `tui-testing` — TUI element and screen rendering. - `gui-integration-test-video` or `computer_use` — when the real question is visual and someone needs to look at it. Before escalating, try splitting the problem. Most "untestable" code is a thin shell of IO wrapped around logic that tests fine once separated: extract the decision-making into a pure function, unit test that, and let an integration test cover the thin shell that remains. That is usually cheaper than either a heavily stubbed unit test or a full app-boot test. That said, weigh the indirection on its own merits. Code that is hard to test is *sometimes* genuinely badly designed — if extracting the logic would make the code clearer regardless of testing, do it. If the seam would exist only to satisfy a test, don't. ## When NOT to write a unit test Tests cost real maintenance, and a bad test costs more than no test. Skip or delete these: - **Change-detector tests.** A test that restates the implementation — inject two collaborators, assert they were called in order — fails on every refactor and catches no defects. It has negative value. Rewrite it as a state assertion or delete it. - **Trivial code with no logic.** Getters, `From`/`Into` passthroughs, `Default` impls, plain struct construction. There is nothing that can break independently. - **Code you don't own.** Don't test the standard library, `tokio`, or `wgpu`. Test *your usage* of them. - **Redundant tests.** If a case is already covered, a near-identical test adds maintenance cost and no signal. Prune tests as ruthlessly as production code. Don't chase a coverage number. Coverage says a line executed, not that anything was verified, and a target reliably turns into a ceiling. ## Where unit tests live - Put unit tests in separate files named `${filename}_tests.rs` or `mod_test.rs`. - Include the test module at the end of the corresponding source file: ```rust #[cfg(test)] #[path = "filename_tests.rs"] // or "mod_test.rs" mod tests; ``` ## Writing good tests Aim for a test you never touch again unless the behavior changes. Refactors, new features, and bug fixes should not require editing existing tests; only a deliberate behavior change should. If a refactor breaks your tests, that is usually a defect in the tests. ### Test behavior through the public API Exercise the unit the way its callers do. Reaching into private state makes the test fail on refactors no caller would notice. If a helper type exists only to serve one or two callers, test it through them rather than directly. ### Assert on state, not on interactions Assert what the system *is* after the action, not which functions it called to get there. ```rust // Brittle: still passes if the entry is dropped right after insertion, and // fails on an equivalent refactor that calls a different internal method. assert!(recorder.saw_call_to_insert(id)); // Better: asserts the outcome the caller actually cares about. store.insert(id, entry.clone()); assert_eq!(store.get(id), Some(&entry)); ``` ### One behavior per test, named after that behavior The test name is often the only thing visible in a failure report, so make it a sentence about behavior rather than about the method: ```rust #[test] fn parses_utf8_sequence_when_valid() { /* ... */ } #[test] fn returns_replacement_char_for_invalid_utf8() { /* ... */ } ``` If the name needs an "and", you are testing two behaviors — split it. Structure the body as arrange / act / assert, separated by blank lines. ### Keep the test complete and concise Everything a reader needs to understand the result belongs in the test body; everything irrelevant belongs out of it. Prefer a builder or helper constructor that takes only the fields the test cares about over one large shared fixture. If a test asserts on a specific value, set that value in the test rather than inheriting it from shared setup. ### Prefer duplication over indirection Test code has no tests of its own, so it has to be obviously correct on inspection. Some repetition is a fair price for a test that reads top to bottom. Extract a helper when it removes noise, not merely to remove repetition. ### No logic in tests No conditionals, loops, arithmetic, or string concatenation to compute an expected value. Write expected values literally — computing them re-implements the code under test and can reproduce the same bug in the assertion. ### Make failures diagnosable - Prefer `assert_eq!`/`assert_ne!` over `assert!` for readable diffs. - Add a message when the values alone aren't self-explanatory: `assert_eq!(got, want, "cursor should clamp to line end for {input:?}")`. - Use `#[should_panic]` only when panicking is intended API, and pin the message with `expected = "..."`. ### Repo-specific - Minimize global state; inject dependencies via traits/constructors so logic is testable without heavy mocking. - When adding an enum variant or expanding behavior, prefer exhaustive matches in the code under test and mirror the new cases in tests. - Be mindful of terminal model locking: avoid patterns that acquire multiple `model.lock()` calls in the same call stack from tests, and prefer passing an already-locked reference down. ## Test doubles: prefer real code, then fakes Work down this list and stop at the first option that is fast and deterministic: 1. **The real implementation.** The default for value types and pure logic. Running real collaborator code is what makes a test meaningful, and a failure caused by a real dependency's bug is a true positive worth having. 2. **A fake the repo already provides.** These are maintained alongside the real thing, so they don't drift the way hand-rolled doubles do: `warpui::App::test`, `VirtualFS`, `TerminalModel::mock(..)`, `TestBlockListBuilder`/`TestBlockBuilder`, `Appearance::mock()`, and `FeatureFlag::X.override_enabled(..)`. See "Common helpers to use" below for usage. 3. **A stubbed return value**, only to push the unit into a state you cannot otherwise reach, such as a rare error branch. Each stub should map to an assertion in the same test. Needing many stubs is a signal the unit does too much. 4. **Asserting that a call happened**, as a last resort, and only for state-changing effects (a write, a send, a spawn) whose result you cannot observe any other way. Never assert on calls to pure getters — the return value is already covered by whatever you assert next. ## Keeping tests deterministic A flaky test is worse than no test: once people learn to re-run a red test, they stop trusting every other test too. Fix the cause instead of adding retries. - **Time** — never read the system clock from logic under test. Inject a clock or timestamp so the test can pin it. - **Async** — never sleep to wait for something. Await the future, use a callback, or poll for the state transition with a generous timeout. - **Ordering** — tests must pass in any order and in parallel. Watch for statics, singletons, `OnceCell`, and environment variables. - **Shared state** — when a test must touch global or external state, use `serial_test`'s `#[serial]` or scope the state locally. If you can't make a test deterministic quickly, quarantine it (`#[ignore]` with a linked issue) rather than leaving an intermittently red test in the suite — and treat that as debt to pay down, not a place to leave it. ## Async and feature-gated code - For async logic, use `#[tokio::test]` when the code requires a runtime. - Prefer runtime feature checks (e.g., `FeatureFlag::X.is_enabled()`) over `#[cfg(...)]` so tests don’t require recompilation to toggle behavior. ## Quickstart harness (UI/model tests) - Prefer `warpui::App::test` for deterministic unit tests around views/models. - Initialize app models once, then mutate via `update` and assert via `read`. ```rust use warpui::App; // In app crate tests prefer `crate::test_util::...`; from other crates use `warp::test_util::...`. use warp::test_util::{terminal::initialize_app_for_terminal_view, add_window_with_terminal}; #[test] fn example() { App::test((), |mut app| async move { // One-time app setup for terminal/view tests initialize_app_for_terminal_view(&mut app); // includes settings init let term = add_window_with_terminal(&mut app, None); // Act term.update(&mut app, |view, _ctx| { view.model.lock().simulate_block("ls", "out"); }); // Assert term.read(&app, |view, _ctx| { assert!(view.model.lock().block_list().len() > 0); }); }) } ``` ## TUI element tests Tests for the headless TUI render an element tree to text lines rather than drawing pixels. Use `warpui_core::elements::tui::test_support::render_to_lines` and `TuiBuffer::to_lines`, and keep them in `*_tests.rs` files next to the source in `crates/warp_tui` and `crates/warpui_core/src/elements/tui`. They are plain unit tests and do NOT use the GUI integration / real-display / `computer_use` framework. See the `tui-testing` skill for details. The `warpui::App::test` harness above still applies to shared model logic that both front-ends use. ## Common helpers to use - Terminal model shortcuts: `TerminalModel::mock(..)`, `.simulate_block(..)`, `.finish_block()`, `.simulate_cmd(..)`. - Builders for focused tests: `terminal::model::test_utils::{TestBlockListBuilder, TestBlockBuilder}`. - Virtual filesystem for IO-heavy code: ```rust use virtual_fs::{VirtualFS, Stub}; VirtualFS::test("case", |_dirs, mut fs| { fs.with_files(vec![Stub::FileWithContent("path/file.txt", "contents")]); // run logic and assert }); ``` - Feature flags (scoped): ```rust use warp::features::FeatureFlag; // or `use crate::features::FeatureFlag;` inside the app crate let _flag = FeatureFlag::CreatingSharedSessions.override_enabled(true); ``` - UI numeric assertions (lines): ```rust assert_lines_approx_eq!(actual_lines, INLINE_BANNER_HEIGHT); ``` - Concurrency: keep `model.lock()` scopes minimal; avoid nested/re-entrant locks in the same call chain. - Don’t call `initialize_settings_for_tests` directly when using `initialize_app_for_terminal_view` (it already calls it). - Async needs: use `#[tokio::test]` when a real runtime is required; otherwise prefer `App::test`. - Tests touching global/external state: consider `serial_test`'s `#[serial]` or local mocking instead of parallelism. ## Running unit tests - Workspace (parallel): ```bash cargo nextest run --no-fail-fast --workspace ``` - Single crate: ```bash cargo nextest run -p <crate_name> ``` - Single test (filter by name): ```bash cargo nextest run -E 'test(<substring>)' ``` - Doc tests: ```bash cargo test --doc ``` ## Final validation order After the relevant tests pass, run Clippy, fix its findings, and then format once: ```bash cargo clippy -p <package_name> --all-targets --tests -- -D warnings ./script/format ``` Do not rerun tests or Clippy after formatting, and do not add a full `./script/presubmit` run, unless the user, task, or approved spec explicitly requires it. Follow the repository's Implementation Validation Order in `AGENTS.md` for invalidation and follow-up changes.
GitHub에서 보기