refactor tests

* factor out duplicated code
* make tests an importable package
* idiomatic pytest usage
* do not touch files outside of the tmp test tree and do not depend on
  external state (except for /nix/store ☹)
* run coverage to root out dead code
This commit is contained in:
Arthur Noel 2023-11-28 04:51:51 +00:00
parent c3c23453e4
commit 0d145c01d5
12 changed files with 252 additions and 240 deletions

View file

@ -4,6 +4,11 @@ line-length = 88
select = ["E", "F", "I"] select = ["E", "F", "I"]
ignore = [ "E501" ] ignore = [ "E501" ]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"pytest.skip",
]
[tool.mypy] [tool.mypy]
python_version = "3.10" python_version = "3.10"
warn_redundant_casts = true warn_redundant_casts = true

0
tests/__init__.py Normal file
View file

105
tests/case.py Normal file
View file

@ -0,0 +1,105 @@
from __future__ import annotations
import functools
import logging
import os
import shutil
import subprocess
import textwrap
from pathlib import Path
import pytest
CMD = Path | str
log = logging.getLogger(__name__)
TEST_ROOT = Path(__file__).parent
PROJECT_ROOT = TEST_ROOT.parent
NIX_DIRENV = PROJECT_ROOT / "direnvrc"
class TestCase:
@pytest.fixture(autouse=True)
def _setup(self, tmp_path: Path) -> None:
self.root = tmp_path
log.debug(f"path: {self.root}")
@functools.cached_property
def home(self) -> Path:
return self.root / "home"
@functools.cached_property
def path(self) -> Path:
path = self.root / "cwd"
shutil.copytree(TEST_ROOT / "testenv", path)
return path
def _log_output(
self,
result: subprocess.CompletedProcess | subprocess.CalledProcessError,
errlevel: str = "debug",
) -> None:
for out in ("stdout", "stderr"):
text = getattr(result, out).strip()
setattr(result, out, text)
if text:
getattr(log, errlevel if out == "stderr" else "debug")(
f"{out[3:]}:{os.linesep if os.linesep in text else ' '}{text}"
)
def run(
self,
*cmd: CMD,
**env: str,
) -> subprocess.CompletedProcess:
env = dict(PATH=os.environ["PATH"], HOME=str(self.home)) | env
command = list(map(str, cmd))
log.debug(f"$ {subprocess.list2cmdline(command)}")
try:
result = subprocess.run(
command,
capture_output=True,
check=True,
text=True,
cwd=self.path,
env=env,
)
except subprocess.CalledProcessError as exc: # pragma: no cover
self._log_output(exc, errlevel="error")
raise
self._log_output(result)
return result
def nix_run(self, *cmd: CMD, **env: str) -> subprocess.CompletedProcess:
return self.run("nix", *cmd, **env)
def direnv_run(self, *cmd: CMD, **env: str) -> subprocess.CompletedProcess:
return self.run("direnv", *cmd, **env)
def direnv_exec(self, *cmd: CMD, **env: str) -> subprocess.CompletedProcess:
return self.run("direnv", "exec", ".", *cmd, **env)
def direnv_var(self, name: str, **env: str) -> subprocess.CompletedProcess:
return self.direnv_exec("sh", "-c", f"echo -n ${name}", **env)
def setup_envrc(
self, content: str, strict_env: bool, **env: str
) -> subprocess.CompletedProcess:
text = textwrap.dedent(
f"""
{'strict_env' if strict_env else ''}
source {NIX_DIRENV}
{content}
""",
)
(self.path / ".envrc").write_text(text.strip())
return self.direnv_run("allow", **env)
def assert_direnv_var(self, name: str, **env: str) -> subprocess.CompletedProcess:
result = self.direnv_var(name, **env)
assert result.stdout == "OK"
assert "renewed cache" in result.stderr
return result

View file

@ -1,4 +0,0 @@
pytest_plugins = [
"direnv_project",
"root",
]

View file

@ -1,49 +0,0 @@
import shutil
import textwrap
from dataclasses import dataclass
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import Iterator
import pytest
from procs import run
@dataclass
class DirenvProject:
dir: Path
nix_direnv: Path
@property
def envrc(self) -> Path:
return self.dir / ".envrc"
def setup_envrc(self, content: str, strict_env: bool) -> None:
text = textwrap.dedent(
f"""
{'strict_env' if strict_env else ''}
source {self.nix_direnv}
{content}
"""
)
self.envrc.write_text(text)
run(["direnv", "allow"], cwd=self.dir)
@pytest.fixture
def direnv_project(test_root: Path, project_root: Path) -> Iterator[DirenvProject]:
"""
Setups a direnv test project
"""
with TemporaryDirectory() as _dir:
dir = Path(_dir) / "proj"
shutil.copytree(test_root / "testenv", dir)
shutil.copyfile(project_root / "flake.nix", dir / "flake.nix")
shutil.copyfile(project_root / "flake.lock", dir / "flake.lock")
nix_direnv = project_root / "direnvrc"
c = DirenvProject(Path(dir), nix_direnv)
try:
yield c
finally:
pass

View file

@ -1,23 +0,0 @@
import subprocess
from pathlib import Path
from typing import IO, Any, List, Optional, Union
_FILE = Union[None, int, IO[Any]]
_DIR = Union[None, Path, str]
def run(
cmd: List[str],
text: bool = True,
check: bool = True,
cwd: _DIR = None,
stderr: _FILE = None,
stdout: _FILE = None,
env: Optional[dict[str, str]] = None,
) -> subprocess.CompletedProcess:
if cwd is not None:
print(f"cd {cwd}")
print("$ " + " ".join(cmd))
return subprocess.run(
cmd, text=text, check=check, cwd=cwd, stderr=stderr, stdout=stdout, env=env
)

View file

@ -1,22 +0,0 @@
from pathlib import Path
import pytest
TEST_ROOT = Path(__file__).parent.resolve()
PROJECT_ROOT = TEST_ROOT.parent
@pytest.fixture
def test_root() -> Path:
"""
Root directory of the tests
"""
return TEST_ROOT
@pytest.fixture
def project_root() -> Path:
"""
Root directory of the tests
"""
return PROJECT_ROOT

View file

@ -1,92 +1,65 @@
import subprocess from __future__ import annotations
import sys
import unittest import json
import pytest import pytest
from direnv_project import DirenvProject
from procs import run from .case import TestCase
def common_test(direnv_project: DirenvProject) -> None: class TestGc(TestCase):
run(["nix-collect-garbage"]) def common_test(self) -> None:
result = self.direnv_exec("hello")
assert "renewed cache" in result.stderr
assert "Executing shellHook." in result.stderr
testenv = str(direnv_project.dir) self.nix_run("store", "gc")
out1 = run( result = self.direnv_exec("hello")
["direnv", "exec", testenv, "hello"], assert "using cached dev shell" in result.stderr
stderr=subprocess.PIPE, assert "Executing shellHook." in result.stderr
check=False,
cwd=direnv_project.dir,
)
sys.stderr.write(out1.stderr)
assert out1.returncode == 0
assert "renewed cache" in out1.stderr
assert "Executing shellHook." in out1.stderr
run(["nix-collect-garbage"]) def common_test_clean(self) -> None:
self.direnv_exec("hello")
files = [path for path in (self.path / ".direnv").iterdir() if path.is_file()]
rcs = [f for f in files if f.match("*.rc")]
profiles = [f for f in files if not f.match("*.rc")]
assert len(rcs) == 1, files
assert len(profiles) == 1, files
out2 = run( @pytest.mark.parametrize("strict_env", [False, True])
["direnv", "exec", testenv, "hello"], def test_use_nix(self, strict_env: bool) -> None:
stderr=subprocess.PIPE, self.setup_envrc("use nix", strict_env=strict_env)
check=False, self.common_test()
cwd=direnv_project.dir,
)
sys.stderr.write(out2.stderr)
assert out2.returncode == 0
assert "using cached dev shell" in out2.stderr
assert "Executing shellHook." in out2.stderr
self.setup_envrc(
"use nix --argstr shellHook 'echo Executing hijacked shellHook.'",
strict_env=strict_env,
)
self.common_test_clean()
def common_test_clean(direnv_project: DirenvProject) -> None: def _parse_inputs(self, inputs: dict) -> list:
testenv = str(direnv_project.dir) paths = [inputs["path"]]
for xinput in inputs["inputs"].values():
paths.extend(self._parse_inputs(xinput))
return paths
out3 = run( @pytest.mark.parametrize("strict_env", [False, True])
["direnv", "exec", testenv, "hello"], def test_use_flake(self, strict_env: bool) -> None:
stderr=subprocess.PIPE, self.setup_envrc("use flake", strict_env=strict_env)
check=False, self.common_test()
cwd=direnv_project.dir, inputs = list((self.path / ".direnv/flake-inputs").iterdir())
) flake_inputs = self._parse_inputs(
sys.stderr.write(out3.stderr) json.loads(
self.nix_run(
"flake", "archive", "--json", "--no-write-lock-file"
).stdout
)
)
# should only contain our flake-utils flake
assert len(inputs) == len(flake_inputs)
for symlink in inputs:
assert symlink.is_dir()
files = [ self.setup_envrc("use flake --impure", strict_env=strict_env)
path for path in (direnv_project.dir / ".direnv").iterdir() if path.is_file() self.common_test_clean()
]
rcs = [f for f in files if f.match("*.rc")]
profiles = [f for f in files if not f.match("*.rc")]
if len(rcs) != 1 or len(profiles) != 1:
print(files)
assert len(rcs) == 1
assert len(profiles) == 1
@pytest.mark.parametrize("strict_env", [False, True])
def test_use_nix(direnv_project: DirenvProject, strict_env: bool) -> None:
direnv_project.setup_envrc("use nix", strict_env=strict_env)
common_test(direnv_project)
direnv_project.setup_envrc(
"use nix --argstr shellHook 'echo Executing hijacked shellHook.'",
strict_env=strict_env,
)
common_test_clean(direnv_project)
@pytest.mark.parametrize("strict_env", [False, True])
def test_use_flake(direnv_project: DirenvProject, strict_env: bool) -> None:
direnv_project.setup_envrc("use flake", strict_env=strict_env)
common_test(direnv_project)
inputs = list((direnv_project.dir / ".direnv/flake-inputs").iterdir())
# should only contain our flake-utils flake
if len(inputs) != 4:
run(["nix", "flake", "archive", "--json"], cwd=direnv_project.dir)
print(inputs)
assert len(inputs) == 4
for symlink in inputs:
assert symlink.is_dir()
direnv_project.setup_envrc("use flake --impure", strict_env=strict_env)
common_test_clean(direnv_project)
if __name__ == "__main__":
unittest.main()

View file

@ -1,67 +1,35 @@
from __future__ import annotations
import os import os
import subprocess
import sys
import unittest
from typing import Optional
import pytest import pytest
from direnv_project import DirenvProject
from procs import run from .case import TestCase
def direnv_exec( class TestUseNix(TestCase):
direnv_project: DirenvProject, cmd: str, env: Optional[dict[str, str]] = None @pytest.mark.parametrize("strict_env", [False, True])
) -> None: def test_attrs(self, strict_env: bool) -> None:
args = ["direnv", "exec", str(direnv_project.dir), "sh", "-c", cmd] self.setup_envrc("use nix -A subshell", strict_env=strict_env)
print("$ " + " ".join(args)) self.assert_direnv_var("THIS_IS_A_SUBSHELL")
out = run(
args,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
check=False,
cwd=direnv_project.dir,
env=env,
)
sys.stdout.write(out.stdout)
sys.stderr.write(out.stderr)
assert out.returncode == 0
assert "OK\n" == out.stdout
assert "renewed cache" in out.stderr
@pytest.mark.parametrize("strict_env", [False, True])
def test_with_nix_path(self, strict_env: bool) -> None:
if (nix_path := os.environ.get("NIX_PATH")) is None:
pytest.skip("no parent NIX_PATH")
else:
self.setup_envrc(
"use nix --argstr someArg OK", strict_env=strict_env, NIX_PATH=nix_path
)
self.assert_direnv_var("SHOULD_BE_SET", NIX_PATH=nix_path)
@pytest.mark.parametrize("strict_env", [False, True]) @pytest.mark.parametrize("strict_env", [False, True])
def test_attrs(direnv_project: DirenvProject, strict_env: bool) -> None: def test_args(self, strict_env: bool) -> None:
direnv_project.setup_envrc("use nix -A subshell", strict_env=strict_env) self.setup_envrc("use nix --argstr someArg OK", strict_env=strict_env)
direnv_exec(direnv_project, "echo $THIS_IS_A_SUBSHELL") self.assert_direnv_var("SHOULD_BE_SET")
@pytest.mark.parametrize("strict_env", [False, True])
@pytest.mark.parametrize("strict_env", [False, True]) def test_no_files(self, strict_env: bool) -> None:
def test_no_nix_path(direnv_project: DirenvProject, strict_env: bool) -> None: self.setup_envrc("use nix -p hello", strict_env=strict_env)
direnv_project.setup_envrc("use nix --argstr someArg OK", strict_env=strict_env) result = self.direnv_run("status")
env = os.environ.copy() assert 'Loaded watch: "."' not in result.stdout
del env["NIX_PATH"]
direnv_exec(direnv_project, "echo $SHOULD_BE_SET", env=env)
@pytest.mark.parametrize("strict_env", [False, True])
def test_args(direnv_project: DirenvProject, strict_env: bool) -> None:
direnv_project.setup_envrc("use nix --argstr someArg OK", strict_env=strict_env)
direnv_exec(direnv_project, "echo $SHOULD_BE_SET")
@pytest.mark.parametrize("strict_env", [False, True])
def test_no_files(direnv_project: DirenvProject, strict_env: bool) -> None:
direnv_project.setup_envrc("use nix -p hello", strict_env=strict_env)
out = run(
["direnv", "status"],
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
check=False,
cwd=direnv_project.dir,
)
assert out.returncode == 0
assert 'Loaded watch: "."' not in out.stdout
if __name__ == "__main__":
unittest.main()

View file

@ -1,2 +0,0 @@
source ../../direnvrc
use nix

61
tests/testenv/flake.lock generated Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1694529238,
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1700856099,
"narHash": "sha256-RnEA7iJ36Ay9jI0WwP+/y4zjEhmeN6Cjs9VOFBH7eVQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "0bd59c54ef06bc34eca01e37d689f5e46b3fe2f1",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View file

@ -1,6 +1,6 @@
{ {
description = "A very basic flake"; description = "A very basic flake";
inputs.nixpkgs.url = "github:NixOS/nixpkgs"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
inputs.flake-utils.url = "github:numtide/flake-utils"; inputs.flake-utils.url = "github:numtide/flake-utils";
outputs = { self, nixpkgs, flake-utils }: outputs = { self, nixpkgs, flake-utils }: