cd /news/ai-agents/an-ai-agent-skill-for-writing-tests-โ€ฆ ยท home โ€บ topics โ€บ ai-agents โ€บ article
[ARTICLE ยท art-72541] src=gist.github.com โ†— pub= topic=ai-agents verified=true sentiment=ยท neutral

An AI agent skill for writing tests in the `nushell` repo.

A developer created an AI agent skill for writing tests in the Nushell repository, emphasizing the use of modern `nu_test_support` infrastructure over older subprocess-style testing. The skill provides guidelines for in-process tests, direct value assertions, and explicit dependencies.

read19 min views23 publishedJul 19, 2026
name nushell-testing
description Use when writing or refactoring Nushell Rust tests that use nu_test_support, NuTester, Playground, rstest, deps, plugins, CompleteResult, or when replacing old nu!/nu_with_plugins! tests.
license MIT
compatibility opencode
metadata

Use this skill when writing new tests or refactoring old tests in the Nushell repository. The goal is to use the modern nu_test_support

infrastructure directly, keep tests small and explicit, and avoid subprocess-style testing unless the behavior under test truly crosses the nu

binary boundary.

The most important rule: do not use nu!

in new tests. Do not introduce nu!

, nu_with_plugins!

, nu_with_std!

, or nu_repl_code

. Some of these APIs may still exist for compatibility in older branches, but new and refactored tests should use test()

and NuTester

instead.

Prefer in-process tests. test()

returns a NuTester

that runs Nushell code in-process against the current crate code. This is faster, more deterministic, and avoids stale nu

binaries.

Assert values, not text rendering. If a command returns a list, record, bool, int, table, duration, or structured error, assert that structured value directly. Do not add to json

, to nuon

, to text

, str join

, or manual output collapsing just to make assertion easier.

Make required external artifacts explicit. If a test needs a compiled nu

binary, use #[deps(NU)]

. If it needs a plugin, use #[deps(NU_PLUGIN_EXAMPLE)]

or the appropriate plugin dependency. Do not call add_nu_to_path()

in new code.

Keep tests local and readable. Avoid tiny helper functions that hide the testing infra. Prefer a few explicit lines in the test body, rstest

cases for repeated shapes, and constants only for large shared data or setup snippets.

Return Result

. Most Nushell tests should be fn test_name() -> Result

, where Result

comes from nu_test_support::prelude::*

. Let ?

propagate TestError

from NuTester

and assertion helpers.

Use the newest guidance over older examples. Older refactors may use add_nu_to_path()

; newer test infra replaces that with #[deps(NU)]

.

Most test files should start with the prelude:

use nu_test_support::prelude::*;

Add specific imports when needed:

use nu_protocol::Signals;
use pretty_assertions::assert_matches;
use rstest::rstest;

Do not import test_value!

, test_record!

, or test_table!

again; those test_*!

macros are already provided by nu_test_support::prelude::*

.

Do not import the old macros for new tests:

// Bad
use nu_test_support::nu;
use nu_test_support::{nu, nu_repl_code};
use nu_test_support::nu_with_plugins;

Why: even when nu

is still re-exported by compatibility modules or the prelude on some branches, that is not permission to use it in new tests. The direction is to remove these macros from tests.

For a single snippet and a single expected value, chain test().run(...).expect_value_eq(...)

:

use nu_test_support::prelude::*;

#[test]
fn can_average_range() -> Result {
    test().run("0..5 | math avg").expect_value_eq(2.5)
}

This is better than a nu!

test because it compares a real Value

converted through IntoValue

, not collapsed stdout text.

Do not write this:

use nu_test_support::nu;

#[test]
fn can_average_range() {
    let actual = nu!("0..5 | math avg");
    assert_eq!(actual.out, "2.5");
}

Use a local variable named code

only for long or multiline snippets:

use nu_test_support::prelude::*;

#[test]
fn format_filesize_respects_float_precision_for_fractional_values() -> Result {
    let code = "
        $env.config = ($env.config | upsert float_precision 5)
        1024B | format filesize kB
    ";

    test().run(code).expect_value_eq("1.02400 kB")
}

Guidelines for multiline snippets:

Use let code = "..."

or let code = r#"..."#

when the snippet is long or multiline.

For short snippets, pass a one-line string directly to run(...)

or run_with_data(...)

.

Keep Nushell code readable; do not compress everything into one line unless it is genuinely clearer.

If the expected output is a multiline Rust string, prefer indoc::indoc!

.

Use cwd

on the tester when the snippet reads files relative to a fixture directory:

use nu_test_support::prelude::*;

#[test]
fn reads_cargo_sample() -> Result {
    let code = r#"
        open cargo_sample.toml
        | get package
        | format pattern "{name} has license {license}"
    "#;

    test()
        .cwd("tests/fixtures/formats")
        .run(code)
        .expect_value_eq("nu has license ISC")
}

cwd

accepts repo-relative paths and sandbox paths. Prefer it over cd

in the snippet when the test setup itself should start in a specific directory.

run

extracts the result into any type implementing FromValue

. expect_value_eq

accepts any type implementing IntoValue

.

Never write run::<T>(...)

or run_with_data::<T>(...)

. If you extract a value, let the left-hand side define the type with let value: T = test().run(...) ?

. If you call expect_value_eq

, expect_shell_error

, expect_parse_error

, or similar, the helper already fixes the needed type.

Simple expected values should be raw Rust values when they are homogeneous or scalar:

#[test]
fn filters_ints() -> Result {
    test()
        .run("[3, 1, 2] | sort")
        .expect_value_eq([1, 2, 3])
}

Use test_value!

, test_record!

, and test_table!

for mixed, nested, record, or table-like data. Prefer test_value!

for nested structures instead of nesting test_record!

inside test_record!

:

use nu_test_support::prelude::*;

#[test]
fn insert_at_end_of_list() -> Result {
    test()
        .run("[1, 2, 3] | insert 3 abc")
        .expect_value_eq(test_value!([1, 2, 3, "abc"]))
}
php
use nu_test_support::prelude::*;

#[test]
fn inserts_into_nested_record() -> Result {
    test()
        .run("{a: {}} | insert a.b.c 0")
        .expect_value_eq(test_value!({
            a: { b: { c: 0 } }
        }))
}
php
use nu_test_support::prelude::*;

#[test]
fn insert_uses_enumerate_index() -> Result {
    let code = "
        [[a]; [7] [6]]
        | enumerate
        | insert b {|el| $el.index + 1 + $el.item.a }
        | flatten
    ";

    test().run(code).expect_value_eq(test_table![
        ["index", "a", "b"];
        [0, 7, 8],
        [1, 6, 8],
    ])
}

Prefer these forms over serialized output:

// Bad unless serialization itself is under test
#[test]
fn insert_at_end_of_list() -> Result {
    test()
        .run("[1, 2, 3] | insert 3 abc | to json --raw")
        .expect_value_eq(r#"[1,2,3,"abc"]"#)
}
php
// Good
#[test]
fn insert_at_end_of_list() -> Result {
    test()
        .run("[1, 2, 3] | insert 3 abc")
        .expect_value_eq(test_value!([1, 2, 3, "abc"]))
}

Exceptions are real rendering and serialization tests, such as to json

, to nuon

, table

, or to text

, where the string representation is the behavior under test.

When assertions need Rust logic, extract a typed value:

#[test]
fn splits_empty_path() -> Result {
    let outcome: bool = test().cwd("tests").run("echo '' | path split | is-empty")?;
    assert!(outcome);
    Ok(())
}

If the test only verifies that a command succeeds and returns a value of a certain type, bind to _

:

#[test]
fn is_terminal_accepts_stdin_flag() -> Result {
    let _: bool = test().run("is-terminal --stdin")?;
    Ok(())
}

If you only care that a command succeeds and returns any value:

#[test]
fn help_does_not_error() -> Result {
    let _: Value = test().run("overlay hide --help")?;
    Ok(())
}

If the command should return nothing

, use let ()

or expect_value_eq(())

:

#[test]
fn definition_compiles() -> Result {
    let () = test().run("def helper [] { 'ok' }")?;
    Ok(())
}

Use run_with_data

when the test needs external data. This keeps the Nushell snippet stable and avoids unsafe or noisy format!

use:

use nu_test_support::prelude::*;

#[test]
fn reads_input_record() -> Result {
    let input = test_record! {
        "name" => "nu",
        "license" => "MIT",
    };

    test()
        .run_with_data("$in | format pattern '{name} has license {license}'", input)
        .expect_value_eq("nu has license MIT")
}

Prefer this over injecting values into source with format!

:

// Bad when only the value changes
#[test]
fn uppercases_name() -> Result {
    let name = "nu";
    test()
        .run(format!("'{name}' | str upcase"))
        .expect_value_eq("NU")
}
php
// Good
#[test]
fn uppercases_name() -> Result {
    let name = "nu";
    test()
        .run_with_data("$in | str upcase", name)
        .expect_value_eq("NU")
}

format!

is fine when you are intentionally generating Nushell syntax, such as an option flag or a large code fixture, and the generated code remains obvious.

Use one mutable NuTester

when later snippets depend on definitions, environment changes, overlays, or variables from earlier snippets:

#[test]
fn use_main_def_env() -> Result {
    let mut tester = test();

    let () = tester.run(r#"module spam { export def --env main [] { $env.SPAM = "spam" } }"#)?;
    let () = tester.run("use spam")?;
    let () = tester.run("spam")?;

    tester.run("$env.SPAM").expect_value_eq("spam")
}

Use run_multiple

when the test intentionally needs to run several pipelines after each other on the same tester and only the final value matters:

#[test]
fn new_overlay_from_const_name() -> Result {
    let commands = [
        "const mod = 'spam'",
        "overlay new $mod",
        "overlay list | last | get name",
    ];

    test().run_multiple(commands).expect_value_eq("spam")
}

Prefer explicit repeated run

calls when intermediate results matter, when some steps are setup with nothing

output, or when the source-unit boundaries are important for understanding the test:

#[test]
fn use_main_def_env() -> Result {
    let mut tester = test();
    let () = tester.run(r#"module spam { export def --env main [] { $env.SPAM = "spam" } }"#)?;
    let () = tester.run("use spam")?;
    let () = tester.run("spam")?;
    tester.run("$env.SPAM").expect_value_eq("spam")
}

Do not use nu_repl_code

. If the test wants REPL-like separate source units, use run_multiple

for a simple sequence where only the final value matters, or repeated tester.run(...)

calls when intermediate steps matter.

Be deliberate about source-unit boundaries:

If behavior must occur in one parse/source unit, put it in one code

block and run once.

If behavior must persist across source units, use repeated run

calls on one mutable tester.

If scenarios are independent, use separate test()

calls or rstest

cases.

Example of independent scenarios using fresh testers:

#[test]
fn module_main_not_found() -> Result {
    let mut tester = test();
    let () = tester.run("module spam {}")?;
    tester
        .run("use spam main")
        .expect_error_code_eq("nu::parser::export_not_found")?;

    let mut tester = test();
    let () = tester.run("module spam {}")?;
    tester
        .run("use spam [ main ]")
        .expect_error_code_eq("nu::parser::export_not_found")
}

Use Playground::setup

when the test needs a sandboxed filesystem:

use nu_test_support::{fs::Stub::EmptyFile, prelude::*};

#[test]
fn rm_deletes_file() -> Result {
    Playground::setup("rm_deletes_file", |dirs, sandbox| {
        sandbox.with_files(&[EmptyFile("delete-me.txt")]);

        test()
            .cwd(dirs.test())
            .run("rm delete-me.txt; 'delete-me.txt' | path exists")
            .expect_value_eq(false)
    })
}

Rules for playground tests:

Return the Playground::setup

result directly from the test.

Use test().cwd(dirs.test())

when the snippet should run in the sandbox.

Use FileWithContentToBeTrimmed

for indented file contents.

When the Rust side needs to read a file, do not use file_contents(path)

. Use std::fs::read_to_string(path)?

or fs::read_to_string(path)?

if std::fs

is imported. io::Error

converts into TestError

, so this works naturally in tests that return Result

:

use std::fs;
use nu_test_support::{fs::Stub::FileWithContent, prelude::*};

#[test]
fn writes_expected_file() -> Result {
    Playground::setup("writes_expected_file", |dirs, sandbox| {
        sandbox.with_files(&[FileWithContent("input.txt", "hello")]);

        let () = test()
            .cwd(dirs.test())
            .run("open input.txt | str upcase | save output.txt")?;

        let contents = fs::read_to_string(dirs.test().join("output.txt"))?;
        assert_eq!(contents, "HELLO");
        Ok(())
    })
}

Do not create a playground when no filesystem behavior is being tested.

Do not write this shape:

// Bad
#[test]
fn bad_playground_shape() -> Result {
    Playground::setup("case", |dirs, sandbox| {
        sandbox.with_files(&[EmptyFile("x")]);
        test().cwd(dirs.test()).run("ls")?;
        Ok(())
    });

    Ok(())
}

Write this instead:

#[test]
fn good_playground_shape() -> Result {
    Playground::setup("case", |dirs, sandbox| {
        sandbox.with_files(&[EmptyFile("x")]);
        let _: Value = test().cwd(dirs.test()).run("ls")?;
        Ok(())
    })
}

Use rstest

when several tests have the same shape and only data changes:

use nu_test_support::prelude::*;
use rstest::rstest;

#[rstest]
#[case::null("null", ())]
#[case::true_("true", true)]
#[case::false_("false", false)]
fn top_level_values_from_json(#[case] json: &str, #[case] expected: impl IntoValue) -> Result {
    test()
        .run_with_data("from json", json)
        .expect_value_eq(expected)
}

This is better than a loop inside one test because every case is reported independently.

When combining rstest

with #[deps]

, #[env]

, #[exp]

, #[serial]

, or other harness attributes, explicitly use the harness test macro:

use nu_test_support::prelude::*;
use rstest::rstest;

#[rstest]
#[case::child_lib_dirs("$env.NU_LIB_DIRS | describe", "list<string>")]
#[case::child_plugin_dirs("$NU_PLUGIN_DIRS | describe", "list<string>")]
#[nu_test_support::test]
#[deps(NU)]
fn child_process_defaults(#[case] child_code: &str, #[case] expected: impl IntoValue) -> Result {
    test()
        .run_with_data("let child_code; nu -n -c $child_code", child_code)
        .expect_value_eq(expected)
}

For per-case dependencies, follow nearby repository style and place the dependency attribute next to the case it belongs to.

Prefer structured error assertions.

If the exact error code is enough, use expect_error_code_eq

:

#[test]
fn insert_past_end_of_list() -> Result {
    test()
        .run("[1, 2, 3] | insert 5 abc")
        .expect_error_code_eq("nu::shell::access_beyond_end")
}

If the variant or fields matter, extract the error and match it:

use nu_test_support::prelude::*;
use pretty_assertions::assert_matches;

#[test]
fn insert_nested_path_into_empty_list_errors_without_underflow() -> Result {
    let err = test().run("[] | insert 0.0 1").expect_shell_error()?;
    assert_matches!(err, ShellError::AccessEmptyContent { .. });
    Ok(())
}

Parse and compile errors have dedicated helpers:

use nu_protocol::Type;
use nu_test_support::prelude::*;
use pretty_assertions::assert_matches;

#[test]
fn parse_function_signature_switch_is_bool() -> Result {
    let err = test()
        .run("def foo [--bar] { let baz: int = $bar }")
        .expect_parse_error()?;

    assert_matches!(err, ParseError::TypeMismatch(Type::Int, Type::Bool, _));
    Ok(())
}

Use ShellErrorExt

for generic or labeled errors when needed:

#[test]
fn glob_without_match_reports_missing_file() -> Result {
    let err = test().run("ls root3*").expect_shell_error()?;
    let msg = err.generic_msg()?;
    assert_contains("file or folder not found", msg);
    Ok(())
}

Avoid this old style:

// Bad
#[test]
fn insert_past_end_of_list() {
    let actual = nu!("[1, 2, 3] | insert 5 abc");
    assert!(actual.err.contains("too large"));
}

Use expect_value_eq

for Nushell values.

Use assert_contains(needle, haystack)

for positive contains checks.

Use pretty_assertions::assert_eq

or pretty_assertions::assert_str_eq

for large Rust-side values or rendered strings when it improves diffs.

Use assert_matches!

for error variants and enum shapes.

Avoid assert!(haystack.contains(needle))

; it gives weaker diagnostics than assert_contains

.

Avoid assert_eq!(actual.out, ...)

because new tests should not produce actual.out

in the first place.

Use assert_contains_not(needle, haystack)

for negative containment checks. Prefer it over hand-written assert!(!haystack.contains(needle))

because it matches the positive assert_contains

style and gives a consistent failure message:

#[test]
fn overlay_list_does_not_contain_hidden_overlay() -> Result {
    let names: String = test().run("overlay list | get name | str join ' '")?;
    assert_contains_not("spam", names);
    Ok(())
}

Most tests should not spawn the nu

binary. Spawn it only when testing behavior that exists at the process boundary, such as CLI flags, script-file execution, process exit behavior, or child process environment defaults.

When spawning nu

, declare #[deps(NU)]

:

use nu_test_support::prelude::*;

#[test]
#[deps(NU)]
fn source_file_relative_to_file() -> Result {
    let result: CompleteResult = test()
        .cwd("tests/parsing/samples")
        .run("nu -n source_file_relative.nu | complete")?;

    assert_eq!(result.exit_code, 0);
    assert_eq!(result.stdout.trim(), "5");
    assert_eq!(result.stderr, "");
    Ok(())
}

Use CompleteResult

for complete

output instead of indexing a record:

#[test]
#[deps(NU)]
fn child_nu_reports_missing_script() -> Result {
    let result: CompleteResult = test().run("nu -n missing-script.nu | complete")?;

    assert_ne!(result.exit_code, 0);
    assert_contains("file_not_found", result.stderr);
    Ok(())
}

Do not use add_nu_to_path()

in new tests:

// Bad
#[test]
fn child_version() -> Result {
    test()
        .add_nu_to_path()
        .run("nu -n -c 'version | get version'")
        .expect_value_eq(env!("CARGO_PKG_VERSION"))
}
php
// Good
#[test]
#[deps(NU)]
fn child_version() -> Result {
    test()
        .run("nu -n -c 'version | get version'")
        .expect_value_eq(env!("CARGO_PKG_VERSION"))
}

Do not call test binaries through nu --testbin ...

. That route is deprecated for tests. Declare the testbin dependency and call the binary directly:

// Bad
#[test]
#[deps(NU)]
fn bad_testbin_call() -> Result {
    test()
        .run("nu --testbin cococo a b c")
        .expect_value_eq("a b c")
}
php
// Good
#[test]
#[deps(TESTBIN_COCOCO)]
fn calls_testbin_directly() -> Result {
    test()
        .run("cococo a b c")
        .expect_value_eq("a b c")
}

Use the appropriate TESTBIN_*

constant from the prelude, such as TESTBIN_COCOCO

, TESTBIN_MEOW

, or TESTBIN_REPEATER

. Add NU

only if the behavior under test specifically requires a child Nushell process in addition to the testbin.

Plugin tests should not use nu_with_plugins!

. Declare plugin dependencies with #[deps]

and call plugin commands directly in-process:

use nu_test_support::prelude::*;

#[test]
#[deps(NU_PLUGIN_EXAMPLE)]
fn call_to_json() -> Result {
    test()
        .run("[42] | example call-decl 'to json' {indent: 4}")
        .expect_value_eq("[\n    42\n]")
}
use nu_test_support::prelude::*;

#[test]
#[deps(NU_PLUGIN_EXAMPLE)]
fn plugin_receives_config() -> Result {
    let code = r#"
        $env.config = {
            plugins: {
                example: {
                    path: "some/path"
                    nested: { bool: true, string: "Hello Example!" }
                }
            }
        }
        example config
    "#;

    test().run(code).expect_value_eq(test_value!({
        path: "some/path",
        nested: {
            bool: true,
            string: "Hello Example!"
        }
    }))
}

If a plugin test specifically needs an actual child nu

process plus a plugin path, declare both dependencies and pass the plugin path as data:

#[test]
#[deps(NU, NU_PLUGIN_INC)]
fn plugin_process_exits_when_nushell_exits() -> Result {
    let pid: u32 = test().run_with_data(
        r#"nu -n --plugins $in -c "'2.0.0' | inc -m; (plugin list).0.pid" | into int"#,
        NU_PLUGIN_INC.path(),
    )?;

    let mut tester = test();
    let () = tester.run("sleep 500ms")?;
    let () = tester.run_with_data("let pid = $in", pid)?;
    tester
        .run("ps | where pid == $pid | is-empty")
        .expect_value_eq(true)
}

Use #[serial]

for plugin tests that check process identity, start/stop timing, or shared process state. Do not mark all plugin tests serial by default.

By default, NuTester

keeps $env.PATH

unset or minimal for deterministic tests.

Use #[deps(NU)]

for the Nushell binary.

Use plugin dependencies for plugins.

Use #[deps(TESTBIN_X)]

for test binaries and invoke them directly by name. Do not route testbins through nu --testbin

.

Use inherit_path()

only when the test intentionally relies on arbitrary system commands.

Use inherit_rust_toolchain_env()

for tests that intentionally spawn cargo

, rustc

, or rustup

.

Example:

#[test]
fn cargo_is_available_when_inheriting_rust_toolchain_env() -> Result {
    test()
        .inherit_rust_toolchain_env()
        .run("cargo --version | split row ' ' | get 0")
        .expect_value_eq("cargo")
}

Do not use inherit_path()

as a workaround for missing #[deps]

.

The custom harness supports attributes on tests:

#[serial]

runs tests sequentially. Use it for process-wide state, heavy IO contention, plugin PID lifecycle tests, or other unavoidable shared-state cases. Do not use global locks instead.

#[env(KEY = "value")]

sets process environment variables for a test group. Use this when engine setup or harness grouping depends on an environment variable.

#[exp(OPTION)]

enables an experimental option. Use it instead of setting experimental options manually in a nu!

call.

#[deps(...)]

declares binaries or plugins that must be built and made available to test()

.

Example:

use nu_experimental::REORDER_CELL_PATHS;
use nu_test_support::prelude::*;

#[test]
#[exp(REORDER_CELL_PATHS)]
fn update_table_cell_respects_reorder_option() -> Result {
    let code = "
        let a = [[foo]; [bar]]
        let b = ($a | update foo.0 'baz')
        $b.0.foo
    ";

    test().run(code).expect_value_eq("baz")
}

For locale-sensitive tests, use #[env(NU_TEST_LOCALE_OVERRIDE = "en_US.UTF-8")]

when the locale must affect harness/engine setup, or test().locale_en()

when configuring only the tester is enough.

For commands whose output is intentionally a string rendering, assert the string directly. Use indoc!

to keep expected output readable.

use indoc::indoc;
use nu_test_support::prelude::*;

#[test]
fn table_padding_zero() -> Result {
    test()
        .run_with_data(
            "
                let data = $in
                $env.config.table.padding = {left: 0, right: 0}
                $data | table --width=80
            ",
            test_table![
                ["a", "b", "c"];
                [1, 2, 3],
                [4, 5, [1, 2, 3]],
            ],
        )
        .expect_value_eq(indoc! {"
            โ•ญโ”€โ”ฌโ”€โ”ฌโ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
            โ”‚#โ”‚aโ”‚bโ”‚      c       โ”‚
            โ”œโ”€โ”ผโ”€โ”ผโ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค
            โ”‚0โ”‚1โ”‚2โ”‚             3โ”‚
            โ”‚1โ”‚4โ”‚5โ”‚[list 3 items]โ”‚
            โ•ฐโ”€โ”ดโ”€โ”ดโ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
        "})
}

Notes for rendering tests:

Use run_with_data

for complex input so the test focuses on rendering.

Use ansi strip

when colors are not the behavior under test.

When colors are the behavior under test, assert the ANSI string explicitly.

Use pretty_assertions::assert_str_eq!

when comparing large rendered strings extracted as String

.

Use this checklist when converting old tests.

  • Remove use nu_test_support::nu

,nu_with_plugins

, andnu_repl_code

imports. - Add use nu_test_support::prelude::*;

and only specific extra imports that are needed. - Change fn test_name()

tofn test_name() -> Result

. - Replace let actual = nu!(...)

withtest().run(...)

,test().cwd(...).run(...)

, orlet mut tester = test(); tester.run(...)

. - Replace assert_eq!(actual.out, "...")

with.expect_value_eq(...)

or typed extraction. - Replace assert!(actual.err.contains(...))

with.expect_error_code_eq(...)

,.expect_shell_error()?

,.expect_parse_error()?

, or.expect_compile_error()?

. - Replace plugin macro usage with #[deps(NU_PLUGIN_...)]

and direct plugin commands. - Replace add_nu_to_path()

with#[deps(NU)]

. - Replace nu --testbin name ...

with#[deps(TESTBIN_NAME)]

and directname ...

calls. - Replace nu_repl_code

withrun_multiple

or a mutableNuTester

and repeatedrun

calls, depending on which is clearer. - Replace serialization-only assertions with structured values unless serialization is the feature under test.

  • Use rstest

when the refactor exposes several same-shaped tests. - Keep the test body explicit rather than moving a small sequence into a helper.

Example refactor:

// Old
use nu_test_support::nu;

#[test]
fn insert_into_list() {
    let actual = nu!("[1, 2, 3] | insert 1 abc | to json -r");
    assert_eq!(actual.out, r#"[1,"abc",2,3]"#);
}
php
// New
use nu_test_support::prelude::*;

#[test]
fn insert_into_list() -> Result {
    test()
        .run("[1, 2, 3] | insert 1 abc")
        .expect_value_eq(test_value!([1, "abc", 2, 3]))
}

Error refactor:

// Old
#[test]
fn list_unknown_long_flag() {
    let actual = nu!("ls --full-path");
    assert!(actual.err.contains("Did you mean: `--full-paths`?"));
}
// New
use nu_protocol::ParseError;
use nu_test_support::prelude::*;
use pretty_assertions::assert_matches;

#[test]
fn list_unknown_long_flag() -> Result {
    let err = test().run("ls --full-path").expect_parse_error()?;
    assert_matches!(
        err,
        ParseError::UnknownFlag(_, _, _, help) if help == "Did you mean: `--full-paths`?"
    );
    Ok(())
}

Plugin refactor:

// Old
#[test]
fn call_reduce() {
    let result = nu_with_plugins!(
        cwd: ".",
        plugin: ("nu_plugin_example"),
        "[1 2 3] | example call-decl 'reduce' {fold: 10} { |it, acc| $it + $acc }"
    );
    assert_eq!("16", result.out);
}
php
// New
#[test]
#[deps(NU_PLUGIN_EXAMPLE)]
fn call_reduce() -> Result {
    test()
        .run("[1 2 3] | example call-decl 'reduce' {fold: 10} {|it, acc| $it + $acc}")
        .expect_value_eq(16)
}

Multiple source-unit refactor:

// Acceptable when only the final value matters
#[test]
fn new_overlay_from_const_name() -> Result {
    let commands = [
        "const mod = 'spam'",
        "overlay new $mod",
        "overlay list | last | get name",
    ];

    test().run_multiple(commands).expect_value_eq("spam")
}
// Prefer this when intermediate steps or assertions matter
#[test]
fn new_overlay_from_const_name() -> Result {
    let mut tester = test();
    let () = tester.run("const mod = 'spam'")?;
    let () = tester.run("overlay new $mod")?;
    tester.run("overlay list | last | get name").expect_value_eq("spam")
}

Do not use nu!

for new tests.

Do not use nu_with_plugins!

.

Do not use nu_repl_code

.

Do not use add_nu_to_path()

.

Do not call testbins via nu --testbin

; use #[deps(TESTBIN_...)]

and call them directly.

Do not assert actual.out

, actual.err

, or actual.status

from nu!

output.

Do not write run::<T>(...)

or run_with_data::<T>(...)

; annotate the left-hand binding or use the expectation helpers.

Do not depend on NU

just because a test used to spawn nu

; first ask whether the behavior can be tested in-process.

Do not depend on a plugin or binary without #[deps(...)]

.

Do not serialize values to JSON, NUON, markdown, text, or joined strings only to assert them.

Do not use Playground::setup

when no filesystem behavior is needed.

Do not use file_contents(path)

; use std::fs::read_to_string(path)?

instead.

Do not emulate serialization or isolation with global mutexes; use #[serial]

when serial execution is truly required.

Do not create helper functions just to wrap test().run

or hide a small command sequence.

Do not manually set process environment with std::env::set_var

; use #[env]

or test().env(...)

.

Do not manually enable experimental options in the snippet or old macro options; use #[exp]

.

Do not import test_value

, test_record

, or test_table

from nu_protocol

when nu_test_support::prelude::*

is already imported; the prelude provides the test_*!

macros.

Use test().run(code).expect_value_eq(expected)

for one snippet and one expected value.

Use let mut tester = test(); tester.run(...)

for shared state across source units.

Use test().run_multiple(commands)

for a simple sequence of pipelines where only the last output is asserted.

Use one multiline code

block for behavior that must parse or execute in one source unit.

Use run_with_data

when external Rust data is the input.

Use Playground::setup

plus .cwd(dirs.test())

for sandboxed files.

Use rstest

for same-shaped cases.

Use expect_error_code_eq

when the code is the contract.

Use expect_shell_error

, expect_parse_error

, or expect_compile_error

when the error shape matters.

Use #[deps(NU)]

plus CompleteResult

when testing a child nu

process.

Use #[deps(TESTBIN_...)]

when testing commands that need a testbin, then call the testbin directly.

Use #[deps(NU_PLUGIN_...)]

when testing plugin commands in-process.

Use #[serial]

only for unavoidable shared state, timing-sensitive process lifecycle, or heavy IO conflicts.

Above all, keep the test explicit, type-aware, and free of nu!

.

โ”€โ”€ more in #ai-agents 4 stories ยท sorted by recency
โ”€โ”€ more on @nushell 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain โ€” perfect for shipping the agent you just read about.

$git push zahid main
โ†’ Live at https://your-agent.zahid.host โœ“
Get free account โ†’ Pricing
from โ‚ฌ0/mo ยท no card required
LIVE [news/an-ai-agent-skill-foโ€ฆ] indexed:0 read:19min 2026-07-19 ยท โ€”