Skip to content

Commit 3306d06

Browse files
docs: add plugin testing guide
1 parent 33c248e commit 3306d06

3 files changed

Lines changed: 309 additions & 0 deletions

File tree

‎docs/customization/python_class.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ See [commitizen_cz_template](https://github.com/commitizen-tools/commitizen_cz_t
1919

2020
See [Third-party plugins](../third-party-plugins/about.md) for more details on how to create a third-party Commitizen plugin.
2121

22+
See [Testing plugins](testing_plugins.md) for automated tests of your custom rules.
23+
2224
## Custom commit rules
2325

2426
Create a Python module, for example `cz_jira.py`.
Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
# Testing plugins
2+
3+
Automated tests catch regressions when you change a plugin's rules or upgrade
4+
Commitizen. Start with tests for version bumps, changelog extraction, message
5+
creation, and validation. These tests do not need interactive prompts or a Git
6+
repository.
7+
8+
## Set up a test plugin
9+
10+
Install `pytest` in your plugin's development environment. For an existing
11+
packaged plugin, install it in editable mode as well:
12+
13+
```sh
14+
python -m pip install -e . pytest
15+
```
16+
17+
For the standalone example below, install `commitizen` and `pytest`, then create
18+
`cz_example.py` and `tests/test_plugin.py` in the same project directory. Use the
19+
Commitizen version that your plugin supports. The bump tests below use
20+
`commitizen.version_increment.VersionIncrement`; older versions may instead
21+
provide `commitizen.bump.find_increment` with a different return type.
22+
23+
This example uses `major`, `minor`, and `patch` prefixes. A `docs` message is
24+
valid but does not cause a version bump or appear in the changelog. These are
25+
example rules, not requirements for every plugin.
26+
27+
```python title="cz_example.py"
28+
from collections.abc import Mapping
29+
from typing import Any
30+
31+
from commitizen.cz.base import BaseCommitizen
32+
from commitizen.question import CzQuestion
33+
34+
35+
class ExampleCommitizen(BaseCommitizen):
36+
"""Use explicit version increments as commit prefixes.
37+
38+
Attributes:
39+
config: Configuration supplied to the inherited constructor.
40+
bump_pattern: Pattern whose first group selects a bump token.
41+
bump_map: Mapping from bump tokens to version increments.
42+
changelog_pattern: Pattern selecting commits for the changelog.
43+
commit_parser: Pattern extracting changelog fields.
44+
45+
Example:
46+
```python
47+
from commitizen.config import BaseConfig
48+
49+
plugin = ExampleCommitizen(BaseConfig())
50+
plugin.message({"prefix": "patch", "subject": "repair login"})
51+
```
52+
"""
53+
54+
bump_pattern = r"^(major|minor|patch): "
55+
bump_map = {"major": "MAJOR", "minor": "MINOR", "patch": "PATCH"}
56+
changelog_pattern = r"^(major|minor|patch): "
57+
commit_parser = r"^(?P<change_type>major|minor|patch): (?P<message>[^\r\n]+)"
58+
59+
def questions(self) -> list[CzQuestion]:
60+
"""Ask for the prefix and subject used to build a message."""
61+
return [
62+
{
63+
"type": "list",
64+
"name": "prefix",
65+
"message": "Select the change type",
66+
"choices": ["major", "minor", "patch", "docs"],
67+
},
68+
{"type": "input", "name": "subject", "message": "Commit subject"},
69+
]
70+
71+
def message(self, answers: Mapping[str, Any]) -> str:
72+
"""Build a commit message from the supplied answers."""
73+
return f"{answers['prefix']}: {answers['subject'].strip()}"
74+
75+
def example(self) -> str:
76+
"""Show a valid commit message."""
77+
return "patch: repair login"
78+
79+
def schema(self) -> str:
80+
"""Describe the accepted single-line message format."""
81+
return "<major|minor|patch|docs>: <subject>"
82+
83+
def schema_pattern(self) -> str:
84+
"""Require a known prefix and a non-empty single-line subject."""
85+
return r"\A(major|minor|patch|docs): [^\s\r\n][^\r\n]*\Z"
86+
87+
def info(self) -> str:
88+
"""Explain the example's versioning rules."""
89+
return "Use major for breaking changes, minor for features, patch for fixes."
90+
```
91+
92+
Add these imports and a fixture to `tests/test_plugin.py`. A fixture supplies a
93+
fresh plugin instance for each test. Define it in your own project; Commitizen's
94+
repository fixtures are not installed with the package.
95+
96+
```python title="tests/test_plugin.py"
97+
import re
98+
99+
import pytest
100+
101+
from commitizen.changelog import generate_tree_from_commits
102+
from commitizen.config import BaseConfig
103+
from commitizen.git import GitCommit
104+
from commitizen.version_increment import VersionIncrement
105+
from cz_example import ExampleCommitizen
106+
107+
108+
@pytest.fixture
109+
def plugin() -> ExampleCommitizen:
110+
"""Keep configuration changes isolated between tests."""
111+
return ExampleCommitizen(BaseConfig())
112+
```
113+
114+
Append the following four examples to that test file. Replace the example class
115+
and expected results with your own plugin's rules.
116+
117+
## Test version bumps
118+
119+
`bump_pattern` extracts a token in its first capture group. `bump_map` maps that
120+
token to an increment. Test the result through Commitizen's calculation rather
121+
than only checking that the regular expression matches.
122+
123+
`pytest.mark.parametrize` runs the test once for each input and expected result.
124+
Include all increments, no matching commits, an empty input, and mixed commits
125+
to check that the highest increment wins. This helper returns
126+
`VersionIncrement.NONE` when no bump is needed.
127+
128+
```python
129+
@pytest.mark.parametrize(
130+
("messages", "expected"),
131+
[
132+
(["patch: repair login"], VersionIncrement.PATCH),
133+
(["minor: add export"], VersionIncrement.MINOR),
134+
(["major: remove old API"], VersionIncrement.MAJOR),
135+
(["docs: update README"], VersionIncrement.NONE),
136+
(["patchwork: unrelated text"], VersionIncrement.NONE),
137+
([], VersionIncrement.NONE),
138+
(["patch: repair login", "minor: add export"], VersionIncrement.MINOR),
139+
(["major: remove old API", "patch: repair login"], VersionIncrement.MAJOR),
140+
],
141+
)
142+
def test_bump(
143+
plugin: ExampleCommitizen, messages: list[str], expected: VersionIncrement
144+
) -> None:
145+
"""Check version increments, including precedence and ignored messages."""
146+
# Arrange
147+
pattern = plugin.bump_pattern
148+
bump_map = plugin.bump_map
149+
150+
# Act
151+
increment = VersionIncrement.get_highest_by_messages(messages, pattern, bump_map)
152+
153+
# Assert
154+
assert increment == expected
155+
```
156+
157+
If your plugin supports breaking-change footers or `bump_map_major_version_zero`,
158+
add cases for those rules too. This example does not define those features.
159+
160+
## Test changelog extraction
161+
162+
`changelog_pattern` decides which commits are eligible. `commit_parser` extracts
163+
named fields such as `change_type` and `message`. A match in the first pattern
164+
alone does not guarantee that a changelog entry is produced.
165+
166+
Pass in-memory commits to the changelog generator to test both rules together.
167+
An empty tag list puts the results in the `Unreleased` section. No real commits
168+
are created. Check the extracted text as well as which entries are excluded.
169+
170+
```python
171+
@pytest.mark.parametrize(
172+
("title", "body", "expected"),
173+
[
174+
("patch: repair login", "", {"patch": ["repair login"]}),
175+
("minor: add export", "", {"minor": ["add export"]}),
176+
("major: remove old API", "", {"major": ["remove old API"]}),
177+
("docs: update README", "", {}),
178+
("patchwork: unrelated text", "", {}),
179+
("patch: ", "", {}),
180+
("patch: repair login", "Additional context.", {"patch": ["repair login"]}),
181+
],
182+
)
183+
def test_changelog(
184+
plugin: ExampleCommitizen,
185+
title: str,
186+
body: str,
187+
expected: dict[str, list[str]],
188+
) -> None:
189+
"""Check filtering and parsing, including a commit with a body."""
190+
# Arrange
191+
commits = [GitCommit(rev="abc123", title=title, body=body)]
192+
193+
# Act
194+
releases = list(
195+
generate_tree_from_commits(
196+
commits=commits,
197+
tags=[],
198+
commit_parser=plugin.commit_parser,
199+
changelog_pattern=plugin.changelog_pattern,
200+
)
201+
)
202+
changes = {
203+
kind: [entry["message"] for entry in entries]
204+
for kind, entries in releases[0]["changes"].items()
205+
}
206+
207+
# Assert
208+
assert len(releases) == 1
209+
assert releases[0]["version"] == "Unreleased"
210+
assert changes == expected
211+
```
212+
213+
If your plugin defines `change_type_map` or changelog hooks, pass them to the
214+
generator and assert their effect. Add separate output tests if you customize
215+
rendering; this example checks extracted entries, not rendered Markdown.
216+
217+
## Test message creation
218+
219+
Call `message()` with a dictionary of answers instead of opening an interactive
220+
prompt. Assert the exact string, including spacing and any body or footer your
221+
plugin supports.
222+
223+
```python
224+
def test_message(plugin: ExampleCommitizen) -> None:
225+
"""Check message formatting and removal of surrounding subject whitespace."""
226+
# Arrange
227+
answers = {"prefix": "patch", "subject": " repair login "}
228+
229+
# Act
230+
message = plugin.message(answers)
231+
232+
# Assert
233+
assert message == "patch: repair login"
234+
```
235+
236+
## Test the validation pattern
237+
238+
Check both accepted and rejected messages. Also check that `example()` and
239+
`message()` produce messages accepted by the pattern. Use `re.match`, as
240+
Commitizen's default message validator does. Anchors in this example's pattern
241+
reject additional lines.
242+
243+
```python
244+
@pytest.mark.parametrize(
245+
("message", "expected"),
246+
[
247+
("patch: repair login", True),
248+
("docs: update README", True),
249+
("unknown: repair login", False),
250+
("patch repair login", False),
251+
("patch: ", False),
252+
("", False),
253+
("patch: repair login\nextra text", False),
254+
],
255+
)
256+
def test_schema(plugin: ExampleCommitizen, message: str, expected: bool) -> None:
257+
"""Check the plugin's accepted and rejected message formats."""
258+
# Arrange
259+
pattern = plugin.schema_pattern()
260+
261+
# Act
262+
valid = re.match(pattern, message) is not None
263+
264+
# Assert
265+
assert valid is expected
266+
267+
268+
def test_generated_messages_match_schema(plugin: ExampleCommitizen) -> None:
269+
"""Keep the plugin's example, message builder, and schema consistent."""
270+
# Arrange
271+
answers = {"prefix": "patch", "subject": "repair login"}
272+
pattern = plugin.schema_pattern()
273+
274+
# Act
275+
messages = [plugin.example(), plugin.message(answers)]
276+
277+
# Assert
278+
assert all(re.match(pattern, message) is not None for message in messages)
279+
```
280+
281+
These tests cover the schema pattern, not all `cz check` options, such as message
282+
length limits or allowed prefixes. If you override `validate_commit_message`,
283+
also test that method's results and errors.
284+
285+
## Run and extend the tests
286+
287+
From your plugin project directory, run:
288+
289+
```sh
290+
python -m pytest tests/test_plugin.py
291+
```
292+
293+
Run these tests in continuous integration against the Commitizen versions you
294+
support. The bump and changelog helpers are implementation APIs; check their
295+
signatures when upgrading your dependency.
296+
297+
These examples instantiate the class directly. They do not verify package
298+
installation or `commitizen.plugin` entry-point discovery. For a packaged
299+
plugin, also install it in a clean environment and check that
300+
`cz -n YOUR_PLUGIN_NAME example` loads the intended plugin. For Git-dependent
301+
command tests, use a temporary repository, never your working repository.
302+
303+
For more examples, see Commitizen's
304+
[version increment tests](https://github.com/commitizen-tools/commitizen/blob/master/tests/test_version_increment.py),
305+
[changelog tests](https://github.com/commitizen-tools/commitizen/blob/master/tests/test_changelog.py),
306+
and [commit message tests](https://github.com/commitizen-tools/commitizen/blob/master/tests/test_cz_conventional_commits.py).

‎mkdocs.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ nav:
5959
- Advanced Customization:
6060
- Customize via config file: "customization/config_file.md"
6161
- Customized Python Class: "customization/python_class.md"
62+
- Testing plugins: "customization/testing_plugins.md"
6263
- Changelog Template: "customization/changelog_template.md"
6364
- Tutorials:
6465
- Commit Message Best Practices: "tutorials/writing_commits.md"

0 commit comments

Comments
 (0)