A pytest plugin for testing justfiles. No such tool exists (as of March 2026). This spec is derived from a working hand-rolled implementation in a real project and a detailed design sketch. The goal is a reusable, publishable package.
Justfiles (used by just, a command runner) have failure modes that are easy to miss:
- A recipe is silently renamed or deleted, breaking CI scripts and documentation.
- A dependency chain breaks (e.g.
devstops callinglint). - Variable substitution breaks (a variable stops threading through recipes).
- A parameter is renamed, silently changing behaviour and no longer matching expected invocation/docs. (Source text partially truncated.)
- A multi-step recipe with embedded bash has a logic error.
The interesting behaviour lives in the tools justfiles delegate to (pytest, ruff, cargo, etc.), which have their own tests. But the justfile itself — its recipe graph, parameter contracts, and variable threading — is untested infrastructure.
No off-the-shelf tool addresses this. pytest-just fills the gap.
just (>= 1.13) can emit the full justfile as structured JSON:
just --dump --dump-format jsonThis gives structured access to recipes, dependencies, parameters, attributes, and body content — without string-parsing just --show output.
The top-level object has at least these keys:
{
"aliases": { "<alias_name>": { "target": "<recipe_name>" } },
"assignments": { "<var_name>": { "value": "...", "export": false } },
"recipes": { "...": {} },
"settings": { "...": "..." },
"warnings": ["..."]
}Each recipe in "recipes" has this shape:
{
"<recipe_name>": {
"body": [["<line_fragment>"]],
"dependencies": [
{ "arguments": [], "recipe": "<dep_name>" }
],
"doc": "Recipe doc comment or null",
"name": "<recipe_name>",
"parameters": [
{
"default": null,
"export": false,
"kind": "singular | plus | star",
"name": "<PARAM_NAME>"
}
],
"priors": 0,
"private": true,
"quiet": false,
"shebang": false,
"attributes": []
}
}Body structure: each line in "body" is an array of fragments. A fragment is either a plain string "text" or a structured reference like ["Variable", "var_name"]. This means variable references can be checked structurally (not just via string matching), though string matching on just --show output is simpler for v1.
Important: the JSON dump includes recipes from all imported files (e.g. import "just/utilities.just"), so the full recipe graph is available in a single call.
Initial structure:
pytest-just/
├── pyproject.toml
├── LICENSE # MIT
├── README.md
└── src/
└── pytest_just/
├── __init__.py # Re-export JustfileFixture + version
├── fixture.py # JustfileFixture class
└── plugin.py # Pytest plugin hooks and fixture registration
- Use
pathlibfor all filesystem handling.- Public APIs accept/return
pathlib.Pathwhere path objects are appropriate. - Internal code must not use
os.pathfor new implementation work.
- Public APIs accept/return
- Use
logurufor clear, minimal, actionable logging.- Log only meaningful lifecycle/debug information (discovery, parse/load, command invocation, failures).
- Avoid noisy per-line logging of recipe bodies.
- Use
uvfor Python project/dependency management and task execution.- Local development commands, CI commands, and documentation examples should use
uv(e.g.uv run pytest).
- Local development commands, CI commands, and documentation examples should use
- Use
rufffor linting/format checks andtyfor type checks.- The project quality gate includes both tools.
- Example expectation: lint/format via
ruff, static checks viaty check.
- If a CLI is required beyond pytest plugin options, use
Typer.- Keep CLI thin; delegate business logic to library modules.
- If a database layer is required, use
DuckDB.- Prefer no database for v1 unless there is a clear requirement for persisted/queryable state.
Register via the pytest11 entry point so the plugin activates automatically when the package is installed:
[project.entry-points."pytest11"]
just = "pytest_just.plugin"--justfile-root(default: auto-detect): directory containing the justfile--just-bin(default:"just"): path to thejustbinary
Auto-detection: walk upward from pytestconfig.rootdir looking for a file named justfile or Justfile. Raise FileNotFoundError with a clear message if neither is found.
Provide a session-scoped fixture named just that returns a JustfileFixture instance. Session scope ensures just --dump is called at most once per test run.
Register a justfile marker so users can run justfile tests independently:
def pytest_configure(config):
config.addinivalue_line(
"markers",
"justfile: marks tests as justfile recipe tests",
)JustfileFixture(root: Path, just_bin: str = "just")root: directory containing the justfile. All subprocess calls usecwd=root.just_bin: path or name of thejustbinary.
Parse the justfile once per session via just --dump --dump-format json. Use functools.cached_property for lazy, one-time loading.
If the command fails (exit code != 0), raise RuntimeError with the stderr output and a note that just >= 1.13 is required.
Cache just --show <recipe> results per-recipe in a dict (recipe bodies don't change during a test session).
All accessors raise ValueError with a clear message listing available recipes if the requested recipe doesn't exist.
Methods captured from source screenshots:
recipe_names(*, include_private: bool = False) -> list[str]- Returns list of recipe names.
- Includes private recipes only when
include_private=True.
dependencies(recipe: str) -> list[str]- Returns direct dependency recipe names (from JSON
dependencies).
- Returns direct dependency recipe names (from JSON
parameters(recipe: str) -> list[dict]- Returns parameter dicts.
- Each dict includes
name,default,kind,export.
parameter_names(recipe: str) -> list[str]- Returns parameter name strings.
- Convenience shorthand.
is_shebang(recipe: str) -> bool- Boolean.
- Whether the recipe uses a shebang interpreter.
is_private(recipe: str) -> bool- Boolean.
- Whether the recipe is private.
doc(recipe: str) -> str | None- Doc comment or
None. - The
# commentabove the recipe.
- Doc comment or
body(recipe: str) -> list- Raw body from JSON.
- The structured body array (for advanced assertions).
show(recipe: str) -> str- Full
just --showoutput. - The interpolated recipe text (for string matching).
- Full
assignments() -> dict[str, str]- Top-level variable assignments.
- Variable name -> value.
aliases() -> dict[str, str]- Alias mappings.
- Alias name -> target recipe name.
All assertion methods raise assertion failures with clear messages.
Methods visible in provided screenshots:
-
assert_exists(recipe: str)- Fails if recipe does not exist.
-
assert_depends_on(recipe: str, expected: list[str], transitive: bool = False)- Asserts direct or transitive dependency relationships.
-
assert_parameter(recipe: str, parameter: str)- Fails if a required parameter is missing.
-
assert_body_contains(recipe: str, text: str)- Fails if expected text is not in the recipe body.
- Useful for negative guards (e.g. recipe must not hard-code a path).
-
assert_not_shebang(recipe: str)- Fails if the recipe uses shebang.
- Useful as a guard before dry-run tests.
-
assert_variable_referenced(recipe: str, variable: str)- Fails if the variable is not referenced in the recipe body.
- Uses the structured JSON body (not string matching) to find
["Variable", "<name>"]fragments.
def dry_run(
self,
recipe: str,
*args: str,
env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]- First asserts
not is_shebang(recipe)— shebang recipes execute the interpreter even under--dry-run, which defeats the purpose. - Runs
just --dry-run <recipe> <args>withcwd=self._root. - Merges
envwithos.environ(env overrides take precedence). - Returns the
CompletedProcessfor the caller to inspect stdout and return code.
def _run(
self,
*args: str,
env: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]Central subprocess runner. All calls go through this. Uses:
capture_output=Truetext=Truecheck=False(callers decide how to handle errors)cwd=self._root
def _require(self, recipe: str) -> NoneRaises ValueError if recipe is not in parsed data. Used internally by all accessors before accessing recipe fields.
def _walk_dependencies(
self,
recipe: str,
seen: set[str] | None = None,
) -> set[str]Recursive helper for transitive dependency resolution. Tracks visited recipes to handle cycles gracefully (cycles shouldn't exist in valid justfiles, but defensive coding is appropriate here).
These examples should appear in the README and drive the API design. If the API doesn't support writing tests this cleanly, the API is wrong.
import pytest
REQUIRED_RECIPES = ["test", "build", "lint", "dev", "ci", "clean"]
@pytest.mark.justfile
@pytest.mark.parametrize("recipe", REQUIRED_RECIPES)
def test_recipe_exists(just, recipe):
just.assert_exists(recipe)@pytest.mark.justfile
def test_dev_dependencies(just):
just.assert_depends_on("dev", ["lint", "test"])
@pytest.mark.justfile
def test_ci_transitive(just):
just.assert_depends_on("ci", ["test"], transitive=True)RECIPE_PARAMS = [
("test-file", "FILE"),
("deploy", "ENVIRONMENT"),
("set-version", "VERSION"),
]
@pytest.mark.justfile
@pytest.mark.parametrize("recipe,param", RECIPE_PARAMS)
def test_recipe_parameters(just, recipe, param):
just.assert_parameter(recipe, param)UV_RECIPES = ["test", "lint", "check", "build"]
@pytest.mark.justfile
@pytest.mark.parametrize("recipe", UV_RECIPES)
def test_no_sync_threading(just, recipe):
# Structural check: variable reference exists in body AST
just.assert_variable_referenced(recipe, "no_sync")@pytest.mark.justfile
def test_check_runs_all_tools(just):
just.assert_body_contains("check", "ruff format --check")
just.assert_body_contains("check", "ruff check")
just.assert_body_contains("check", "ty check")SHEBANG_RECIPES = ["deploy", "release", "nb-publish"]
NON_SHEBANG_RECIPES = ["test", "lint", "build"]
@pytest.mark.justfile
@pytest.mark.parametrize("recipe", SHEBANG_RECIPES)
def test_shebang_recipes(just, recipe):
...Note: remaining lines for section 6.6 are truncated in the provided screenshot.
- Execute recipes. All data comes from
--dump,--show, and--dry-run. No recipe is ever run for real. This makes tests safe, fast, and free of side effects. - Parse justfile syntax. We rely entirely on
justitself for parsing. Ifjustcan't parse it, the tests fail at data-loading time with a clear error. We don't reimplement the justfile grammar. - Test the tools recipes delegate to. If
just testrunspytest, we don't re-test pytest. We test that the recipe exists, has the right parameters, and calls the right tool. The tool's own test suite handles the rest. - Support just versions < 1.13. The
--dump --dump-format jsonflag was added in 1.13. Older versions get a clear error message.