Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/test-template.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Test template

on:
push:
branches: [main]
pull_request:

jobs:
structural:
name: Structural tests (py${{ matrix.python-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v7.3.0
with:
python-version: ${{ matrix.python-version }}
- run: uv sync --group test
- run: uv run pytest -m "not integration" -v

integration:
name: Integration tests (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.10", "3.13"]
steps:
- uses: actions/checkout@v6.0.2
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v7.3.0
with:
python-version: ${{ matrix.python-version }}
- name: Install just
run: uv tool install rust-just
- run: uv sync --group test
- run: uv run pytest -m integration -v
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,9 @@ dmypy.json
# Pyre type checker
.pyre/

# uv lock file (not needed for the template repo itself)
uv.lock

# pycharm
.idea
# Local vscode editor config
Expand Down
108 changes: 108 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Contributing to linkml-project-copier

Thank you for considering a contribution!

## Prerequisites

- **Python >= 3.10**
- **uv** -- for dependency management and running tests
- **just** -- only needed if you want to run integration tests locally
(`uv tool install rust-just`)

## Getting started

```shell
git clone https://github.com/linkml/linkml-project-copier.git
cd linkml-project-copier
uv sync --group test
```

## Running the tests

The test suite has two tiers:

```shell
# Structural tests only (fast, ~25 seconds, no just/linkml needed)
uv run pytest -m "not integration" -v

# Integration tests only (slow, minutes, needs just + network)
uv run pytest -m integration -v

# Everything
uv run pytest -v
```

### Important: commit before testing

The tests use copier's Python API with `vcs_ref="HEAD"`, which means copier
generates projects from the **last commit** on the current branch. If you
modify template files without committing, the tests will run against the
old commit and your changes won't be covered.

## Test architecture

### Two tiers

**Structural tests** (`test_generation.py`, `test_options.py`,
`test_licenses.py`) generate projects via copier's Python API and inspect
the output -- file existence, content, template variable substitution. They
are fast (seconds) and need nothing beyond the test dependencies. These
tests must never modify the generated project.

**Integration tests** (`test_integration.py`) generate a project, then run
`just install`, `just test`, `just lint`, and `just gen-doc` via subprocess.
They exercise the full toolchain (uv, linkml, just) and take minutes.

### Fixture design

Generating a project with copier takes 1-3 seconds. With 80+ structural
tests, per-test generation would be very slow. To avoid this:

- **Session-scoped fixtures** (in `conftest.py`) generate each project
variant once and share it across all structural tests. The trade-off:
structural tests must treat the generated project as **read-only**.
- **Module-scoped fixture** for integration tests generates a fresh project
because `just` commands mutate the project directory (installing packages,
generating files).

Available session fixtures: `default_project`, `no_example_project`,
`no_pypi_project`, `no_docs_preview_project`, and `license_project`
(parametrized across all six license types).

### Shared helpers (`tests/helpers.py`)

| Helper | Purpose |
|--------|---------|
| `generate_project(dest, data_overrides)` | Call copier's `run_copy()` with sensible defaults |
| `git_init(project_dir)` | Init git + initial commit (needed for dynamic versioning) |
| `run_just(project_dir, *args)` | Run a just command via subprocess with timeout |
| `DEFAULT_DATA` | Dict of template variable defaults used by all tests |
| `ALL_LICENSES` | List of all six supported license identifiers |

### Adding a new structural test

1. Pick the right fixture. If you need the default project, use
`default_project`. If you need a specific option combination that
doesn't exist yet, add a new session-scoped fixture in `conftest.py`.
2. Put the test in the appropriate module (`test_generation.py` for general
structure, `test_options.py` for boolean flags, `test_licenses.py` for
license variants).
3. Never modify files inside the generated project directory -- session
fixtures are shared.

### Adding a new integration test

Add the test to `test_integration.py`. It receives the `integration_project`
fixture which already has `just install` run in it, so dependencies are
available. Mark the test (or the whole module) with `@pytest.mark.integration`.

## CI

The GitHub Actions workflow `test-template.yml` runs two jobs:

- **structural** -- fast, Python version matrix, Ubuntu only
- **integration** -- slow, OS matrix (Ubuntu + Windows), Python version matrix

Structural tests run on every push and PR. Integration tests also run on
every push and PR but take longer, so they use a smaller matrix focused on
OS coverage.
14 changes: 14 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
[dependency-groups]
test = [
"copier>=9.4.0",
"jinja2-time",
"pytest>=8.0",
"pyyaml",
"tomli>=2.0; python_version < '3.11'",
]

[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"integration: tests that run just commands in generated projects (slow)",
]
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@ see_also:
prefixes:
{{project_slug}}: https://w3id.org/{{github_org}}/{{project_name}}/
linkml: https://w3id.org/linkml/
biolink: https://w3id.org/biolink/
biolink: https://w3id.org/biolink/vocab/
schema: http://schema.org/
PATO: http://purl.obolibrary.org/obo/PATO_
example: https://example.org/
example: http://www.example.org/rdf#
default_prefix: {{project_slug}}
default_range: string

Expand Down Expand Up @@ -83,6 +83,8 @@ slots:

enums:
PersonStatus:
description:
The vital status of a person
permissible_values:
ALIVE:
description: the person is living
Expand Down
Empty file added tests/__init__.py
Empty file.
49 changes: 49 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Fixtures for linkml-project-copier template tests."""

from __future__ import annotations

import pytest

from tests.helpers import ALL_LICENSES, generate_project


# ---------------------------------------------------------------------------
# Session-scoped fixtures for structural tests (read-only, generated once)
# ---------------------------------------------------------------------------


@pytest.fixture(scope="session")
def default_project(tmp_path_factory):
"""Project generated with all defaults and add_example=True."""
dest = tmp_path_factory.mktemp("default")
return generate_project(dest)


@pytest.fixture(scope="session")
def no_example_project(tmp_path_factory):
"""Project generated with add_example=False."""
dest = tmp_path_factory.mktemp("no_example")
return generate_project(dest, {"add_example": False})


@pytest.fixture(scope="session")
def no_pypi_project(tmp_path_factory):
"""Project generated with gh_action_pypi=False."""
dest = tmp_path_factory.mktemp("no_pypi")
return generate_project(dest, {"gh_action_pypi": False})


@pytest.fixture(scope="session")
def no_docs_preview_project(tmp_path_factory):
"""Project generated with gh_action_docs_preview=False."""
dest = tmp_path_factory.mktemp("no_docs_preview")
return generate_project(dest, {"gh_action_docs_preview": False})


@pytest.fixture(scope="session", params=ALL_LICENSES)
def license_project(request, tmp_path_factory):
"""Project generated for each license type. Returns (license_name, project_path)."""
license_name = request.param
dest = tmp_path_factory.mktemp(f"license_{license_name}")
project_path = generate_project(dest, {"license": license_name})
return license_name, project_path
90 changes: 90 additions & 0 deletions tests/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Shared helpers for template tests."""

from __future__ import annotations

import os
import subprocess
from pathlib import Path

from copier import run_copy

TEMPLATE_ROOT = Path(__file__).resolve().parent.parent

ALL_LICENSES = ["MIT", "BSD-3-Clause", "Apache-2.0", "MPL-2.0", "LGPL-3.0-only", "GPL-3.0-only"]

DEFAULT_DATA = {
"project_name": "test-schema",
"project_slug": "test_schema",
"email": "test@example.org",
"full_name": "Test User",
"github_org": "test-org",
"project_description": "A test project.",
"license": "MIT",
"copyright_year": "2025",
"add_example": True,
"gh_action_pypi": True,
"gh_action_docs_preview": True,
}


def generate_project(
dest: Path,
data_overrides: dict | None = None,
) -> Path:
"""Generate a project from the copier template.

Args:
dest: Directory where the project will be generated.
data_overrides: Values to override in DEFAULT_DATA.

Returns:
Path to the generated project directory.
"""
data = {**DEFAULT_DATA, **(data_overrides or {})}
run_copy(
str(TEMPLATE_ROOT),
dest,
data=data,
defaults=True,
unsafe=True,
vcs_ref="HEAD",
)
return dest


def git_init(project_dir: Path) -> None:
"""Initialize a git repo with an initial commit (needed for dynamic versioning)."""
subprocess.run(["git", "init"], cwd=project_dir, check=True, capture_output=True)
subprocess.run(
["git", "config", "user.email", "test@test.com"],
cwd=project_dir,
check=True,
capture_output=True,
)
subprocess.run(
["git", "config", "user.name", "Test"],
cwd=project_dir,
check=True,
capture_output=True,
)
subprocess.run(["git", "add", "."], cwd=project_dir, check=True, capture_output=True)
subprocess.run(
["git", "commit", "-m", "init"],
cwd=project_dir,
check=True,
capture_output=True,
)


def run_just(project_dir: Path, *args: str, timeout: int = 600) -> subprocess.CompletedProcess:
"""Run a just command in the given project directory."""
# Remove VIRTUAL_ENV so the generated project's uv uses its own .venv
env = {k: v for k, v in os.environ.items() if k != "VIRTUAL_ENV"}
return subprocess.run(
["just", *args],
cwd=project_dir,
capture_output=True,
text=True,
timeout=timeout,
env=env,
)
Loading