Skip to content

Commit 7e9f7c0

Browse files
Merge pull request #140 from dalito/issue8-add-template-tests
Add tests for copier template
2 parents c257d68 + 5915bc6 commit 7e9f7c0

12 files changed

Lines changed: 696 additions & 2 deletions
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
name: Test template
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
structural:
10+
name: Structural tests (py${{ matrix.python-version }})
11+
runs-on: ubuntu-latest
12+
strategy:
13+
fail-fast: false
14+
matrix:
15+
python-version: ["3.10", "3.11", "3.12", "3.13"]
16+
steps:
17+
- uses: actions/checkout@v6.0.2
18+
with:
19+
fetch-depth: 0
20+
- uses: astral-sh/setup-uv@v7.3.0
21+
with:
22+
python-version: ${{ matrix.python-version }}
23+
- run: uv sync --group test
24+
- run: uv run pytest -m "not integration" -v
25+
26+
integration:
27+
name: Integration tests (${{ matrix.os }})
28+
runs-on: ${{ matrix.os }}
29+
strategy:
30+
fail-fast: false
31+
matrix:
32+
os: [ubuntu-latest, windows-latest]
33+
python-version: ["3.10", "3.13"]
34+
steps:
35+
- uses: actions/checkout@v6.0.2
36+
with:
37+
fetch-depth: 0
38+
- uses: astral-sh/setup-uv@v7.3.0
39+
with:
40+
python-version: ${{ matrix.python-version }}
41+
- name: Install just
42+
run: uv tool install rust-just
43+
- run: uv sync --group test
44+
- run: uv run pytest -m integration -v

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ dmypy.json
128128
# Pyre type checker
129129
.pyre/
130130

131+
# uv lock file (not needed for the template repo itself)
132+
uv.lock
133+
131134
# pycharm
132135
.idea
133136
# Local vscode editor config

CONTRIBUTING.md

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Contributing to linkml-project-copier
2+
3+
Thank you for considering a contribution!
4+
5+
## Prerequisites
6+
7+
- **Python >= 3.10**
8+
- **uv** -- for dependency management and running tests
9+
- **just** -- only needed if you want to run integration tests locally
10+
(`uv tool install rust-just`)
11+
12+
## Getting started
13+
14+
```shell
15+
git clone https://github.com/linkml/linkml-project-copier.git
16+
cd linkml-project-copier
17+
uv sync --group test
18+
```
19+
20+
## Running the tests
21+
22+
The test suite has two tiers:
23+
24+
```shell
25+
# Structural tests only (fast, ~25 seconds, no just/linkml needed)
26+
uv run pytest -m "not integration" -v
27+
28+
# Integration tests only (slow, minutes, needs just + network)
29+
uv run pytest -m integration -v
30+
31+
# Everything
32+
uv run pytest -v
33+
```
34+
35+
### Important: commit before testing
36+
37+
The tests use copier's Python API with `vcs_ref="HEAD"`, which means copier
38+
generates projects from the **last commit** on the current branch. If you
39+
modify template files without committing, the tests will run against the
40+
old commit and your changes won't be covered.
41+
42+
## Test architecture
43+
44+
### Two tiers
45+
46+
**Structural tests** (`test_generation.py`, `test_options.py`,
47+
`test_licenses.py`) generate projects via copier's Python API and inspect
48+
the output -- file existence, content, template variable substitution. They
49+
are fast (seconds) and need nothing beyond the test dependencies. These
50+
tests must never modify the generated project.
51+
52+
**Integration tests** (`test_integration.py`) generate a project, then run
53+
`just install`, `just test`, `just lint`, and `just gen-doc` via subprocess.
54+
They exercise the full toolchain (uv, linkml, just) and take minutes.
55+
56+
### Fixture design
57+
58+
Generating a project with copier takes 1-3 seconds. With 80+ structural
59+
tests, per-test generation would be very slow. To avoid this:
60+
61+
- **Session-scoped fixtures** (in `conftest.py`) generate each project
62+
variant once and share it across all structural tests. The trade-off:
63+
structural tests must treat the generated project as **read-only**.
64+
- **Module-scoped fixture** for integration tests generates a fresh project
65+
because `just` commands mutate the project directory (installing packages,
66+
generating files).
67+
68+
Available session fixtures: `default_project`, `no_example_project`,
69+
`no_pypi_project`, `no_docs_preview_project`, and `license_project`
70+
(parametrized across all six license types).
71+
72+
### Shared helpers (`tests/helpers.py`)
73+
74+
| Helper | Purpose |
75+
|--------|---------|
76+
| `generate_project(dest, data_overrides)` | Call copier's `run_copy()` with sensible defaults |
77+
| `git_init(project_dir)` | Init git + initial commit (needed for dynamic versioning) |
78+
| `run_just(project_dir, *args)` | Run a just command via subprocess with timeout |
79+
| `DEFAULT_DATA` | Dict of template variable defaults used by all tests |
80+
| `ALL_LICENSES` | List of all six supported license identifiers |
81+
82+
### Adding a new structural test
83+
84+
1. Pick the right fixture. If you need the default project, use
85+
`default_project`. If you need a specific option combination that
86+
doesn't exist yet, add a new session-scoped fixture in `conftest.py`.
87+
2. Put the test in the appropriate module (`test_generation.py` for general
88+
structure, `test_options.py` for boolean flags, `test_licenses.py` for
89+
license variants).
90+
3. Never modify files inside the generated project directory -- session
91+
fixtures are shared.
92+
93+
### Adding a new integration test
94+
95+
Add the test to `test_integration.py`. It receives the `integration_project`
96+
fixture which already has `just install` run in it, so dependencies are
97+
available. Mark the test (or the whole module) with `@pytest.mark.integration`.
98+
99+
## CI
100+
101+
The GitHub Actions workflow `test-template.yml` runs two jobs:
102+
103+
- **structural** -- fast, Python version matrix, Ubuntu only
104+
- **integration** -- slow, OS matrix (Ubuntu + Windows), Python version matrix
105+
106+
Structural tests run on every push and PR. Integration tests also run on
107+
every push and PR but take longer, so they use a smaller matrix focused on
108+
OS coverage.

pyproject.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[dependency-groups]
2+
test = [
3+
"copier>=9.4.0",
4+
"jinja2-time",
5+
"pytest>=8.0",
6+
"pyyaml",
7+
"tomli>=2.0; python_version < '3.11'",
8+
]
9+
10+
[tool.pytest.ini_options]
11+
testpaths = ["tests"]
12+
markers = [
13+
"integration: tests that run just commands in generated projects (slow)",
14+
]

template/src/{{project_slug}}/schema/{% if add_example %}{{ project_slug }}.yaml{% endif %}.jinja

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ see_also:
1111
prefixes:
1212
{{project_slug}}: https://w3id.org/{{github_org}}/{{project_name}}/
1313
linkml: https://w3id.org/linkml/
14-
biolink: https://w3id.org/biolink/
14+
biolink: https://w3id.org/biolink/vocab/
1515
schema: http://schema.org/
1616
PATO: http://purl.obolibrary.org/obo/PATO_
17-
example: https://example.org/
17+
example: http://www.example.org/rdf#
1818
default_prefix: {{project_slug}}
1919
default_range: string
2020

@@ -83,6 +83,8 @@ slots:
8383

8484
enums:
8585
PersonStatus:
86+
description:
87+
The vital status of a person
8688
permissible_values:
8789
ALIVE:
8890
description: the person is living

tests/__init__.py

Whitespace-only changes.

tests/conftest.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Fixtures for linkml-project-copier template tests."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
7+
from tests.helpers import ALL_LICENSES, generate_project
8+
9+
10+
# ---------------------------------------------------------------------------
11+
# Session-scoped fixtures for structural tests (read-only, generated once)
12+
# ---------------------------------------------------------------------------
13+
14+
15+
@pytest.fixture(scope="session")
16+
def default_project(tmp_path_factory):
17+
"""Project generated with all defaults and add_example=True."""
18+
dest = tmp_path_factory.mktemp("default")
19+
return generate_project(dest)
20+
21+
22+
@pytest.fixture(scope="session")
23+
def no_example_project(tmp_path_factory):
24+
"""Project generated with add_example=False."""
25+
dest = tmp_path_factory.mktemp("no_example")
26+
return generate_project(dest, {"add_example": False})
27+
28+
29+
@pytest.fixture(scope="session")
30+
def no_pypi_project(tmp_path_factory):
31+
"""Project generated with gh_action_pypi=False."""
32+
dest = tmp_path_factory.mktemp("no_pypi")
33+
return generate_project(dest, {"gh_action_pypi": False})
34+
35+
36+
@pytest.fixture(scope="session")
37+
def no_docs_preview_project(tmp_path_factory):
38+
"""Project generated with gh_action_docs_preview=False."""
39+
dest = tmp_path_factory.mktemp("no_docs_preview")
40+
return generate_project(dest, {"gh_action_docs_preview": False})
41+
42+
43+
@pytest.fixture(scope="session", params=ALL_LICENSES)
44+
def license_project(request, tmp_path_factory):
45+
"""Project generated for each license type. Returns (license_name, project_path)."""
46+
license_name = request.param
47+
dest = tmp_path_factory.mktemp(f"license_{license_name}")
48+
project_path = generate_project(dest, {"license": license_name})
49+
return license_name, project_path

tests/helpers.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Shared helpers for template tests."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import subprocess
7+
from pathlib import Path
8+
9+
from copier import run_copy
10+
11+
TEMPLATE_ROOT = Path(__file__).resolve().parent.parent
12+
13+
ALL_LICENSES = ["MIT", "BSD-3-Clause", "Apache-2.0", "MPL-2.0", "LGPL-3.0-only", "GPL-3.0-only"]
14+
15+
DEFAULT_DATA = {
16+
"project_name": "test-schema",
17+
"project_slug": "test_schema",
18+
"email": "test@example.org",
19+
"full_name": "Test User",
20+
"github_org": "test-org",
21+
"project_description": "A test project.",
22+
"license": "MIT",
23+
"copyright_year": "2025",
24+
"add_example": True,
25+
"gh_action_pypi": True,
26+
"gh_action_docs_preview": True,
27+
}
28+
29+
30+
def generate_project(
31+
dest: Path,
32+
data_overrides: dict | None = None,
33+
) -> Path:
34+
"""Generate a project from the copier template.
35+
36+
Args:
37+
dest: Directory where the project will be generated.
38+
data_overrides: Values to override in DEFAULT_DATA.
39+
40+
Returns:
41+
Path to the generated project directory.
42+
"""
43+
data = {**DEFAULT_DATA, **(data_overrides or {})}
44+
run_copy(
45+
str(TEMPLATE_ROOT),
46+
dest,
47+
data=data,
48+
defaults=True,
49+
unsafe=True,
50+
vcs_ref="HEAD",
51+
)
52+
return dest
53+
54+
55+
def git_init(project_dir: Path) -> None:
56+
"""Initialize a git repo with an initial commit (needed for dynamic versioning)."""
57+
subprocess.run(["git", "init"], cwd=project_dir, check=True, capture_output=True)
58+
subprocess.run(
59+
["git", "config", "user.email", "test@test.com"],
60+
cwd=project_dir,
61+
check=True,
62+
capture_output=True,
63+
)
64+
subprocess.run(
65+
["git", "config", "user.name", "Test"],
66+
cwd=project_dir,
67+
check=True,
68+
capture_output=True,
69+
)
70+
subprocess.run(["git", "add", "."], cwd=project_dir, check=True, capture_output=True)
71+
subprocess.run(
72+
["git", "commit", "-m", "init"],
73+
cwd=project_dir,
74+
check=True,
75+
capture_output=True,
76+
)
77+
78+
79+
def run_just(project_dir: Path, *args: str, timeout: int = 600) -> subprocess.CompletedProcess:
80+
"""Run a just command in the given project directory."""
81+
# Remove VIRTUAL_ENV so the generated project's uv uses its own .venv
82+
env = {k: v for k, v in os.environ.items() if k != "VIRTUAL_ENV"}
83+
return subprocess.run(
84+
["just", *args],
85+
cwd=project_dir,
86+
capture_output=True,
87+
text=True,
88+
timeout=timeout,
89+
env=env,
90+
)

0 commit comments

Comments
 (0)