pytest-casebook 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,43 @@
1
+ """
2
+ pytest-casebook: a pytest plugin that removes the need to maintain test specification
3
+ sheets and automated tests separately.
4
+
5
+ - Write cases in YAML as "preconditions -> action -> expected result". The action is one of
6
+ running a CLI, calling a Python function, or sending a request to an HTTP API
7
+ - Cases can be classified by groups into any number of levels (e.g. category / subcategory / item),
8
+ and `--casebook-html` outputs the results as a table in that hierarchy (a test specification
9
+ sheet)
10
+
11
+ pytest-casebook: 試験項目表と自動テストの二重管理をなくす pytest プラグイン。
12
+
13
+ - YAML に「前提条件 → 操作 → 期待結果」の形でケースを書く。操作は CLI の実行・
14
+ Python 関数の呼び出し・HTTP の API へのリクエストのどれか
15
+ - ケースはグループで大項目・中項目・小項目のように何階層でも分類でき、
16
+ `--casebook-html` でその階層の表(試験項目表)として結果を出力できる
17
+ """
18
+
19
+ from pathlib import Path
20
+
21
+ from .http import HttpRequest, HttpResponse
22
+ from .model import Case, CasebookDoc, CasebookError, Group, load
23
+ from .runner import CommandResult, run_in_process
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ # JSON Schema for case files (casebook_*.yaml), used for editor completion and validation.
28
+ # ケースファイル(casebook_*.yaml)の JSON Schema。エディタの補完と検証に使う
29
+ SCHEMA_PATH = Path(__file__).resolve().parent / "casebook.schema.json"
30
+
31
+ __all__ = [
32
+ "SCHEMA_PATH",
33
+ "Case",
34
+ "CasebookDoc",
35
+ "CasebookError",
36
+ "CommandResult",
37
+ "Group",
38
+ "HttpRequest",
39
+ "HttpResponse",
40
+ "__version__",
41
+ "load",
42
+ "run_in_process",
43
+ ]
@@ -0,0 +1,401 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "title": "pytest-casebook のケースファイル",
4
+ "description": "casebook_*.yaml の書き方。エディタ(VS Code の YAML 拡張など)でキーの補完と検証に使う。",
5
+ "type": "object",
6
+ "required": [
7
+ "cases"
8
+ ],
9
+ "additionalProperties": false,
10
+ "properties": {
11
+ "title": {
12
+ "type": "string",
13
+ "description": "レポートの見出し(省略時はファイル名)"
14
+ },
15
+ "description": {
16
+ "type": "string",
17
+ "description": "レポートに出す説明"
18
+ },
19
+ "levels": {
20
+ "type": "array",
21
+ "items": {
22
+ "type": "string"
23
+ },
24
+ "description": "階層の呼び名(例: [大項目, 中項目, 小項目])"
25
+ },
26
+ "states": {
27
+ "type": "object",
28
+ "description": "状態名 -> 内容(名前を付けた setup:)。ケースやグループの state: で選ぶ",
29
+ "additionalProperties": {
30
+ "$ref": "#/$defs/setup"
31
+ }
32
+ },
33
+ "command_prefix": {
34
+ "description": "どの command にも先頭に付ける引数。{casebook_dir} は YAML のディレクトリになる",
35
+ "oneOf": [
36
+ {
37
+ "type": "string"
38
+ },
39
+ {
40
+ "type": "array",
41
+ "items": {
42
+ "type": "string"
43
+ }
44
+ }
45
+ ]
46
+ },
47
+ "golden_dir": {
48
+ "type": "string",
49
+ "description": "期待出力の置き場所(このファイルからの相対パス)"
50
+ },
51
+ "check_orphans": {
52
+ "type": "boolean",
53
+ "description": "ケースがない期待出力が残っていたら失敗にする"
54
+ },
55
+ "normalize": {
56
+ "type": "array",
57
+ "description": "出力に当てる置き換え",
58
+ "items": {
59
+ "type": "object",
60
+ "required": [
61
+ "pattern"
62
+ ],
63
+ "additionalProperties": false,
64
+ "properties": {
65
+ "pattern": {
66
+ "type": "string",
67
+ "description": "正規表現"
68
+ },
69
+ "replace": {
70
+ "type": "string",
71
+ "description": "置き換え後"
72
+ }
73
+ }
74
+ }
75
+ },
76
+ "request_defaults": {
77
+ "type": "object",
78
+ "additionalProperties": false,
79
+ "properties": {
80
+ "headers": {
81
+ "$ref": "#/$defs/headers"
82
+ }
83
+ },
84
+ "description": "どの request: にも重ねる既定の値"
85
+ },
86
+ "meta": {
87
+ "type": "object",
88
+ "description": "自由に使う情報(pytest-casebook は見ない)"
89
+ },
90
+ "cases": {
91
+ "$ref": "#/$defs/items"
92
+ }
93
+ },
94
+ "$defs": {
95
+ "items": {
96
+ "type": "array",
97
+ "description": "ケースまたはグループのリスト",
98
+ "items": {
99
+ "oneOf": [
100
+ {
101
+ "$ref": "#/$defs/group"
102
+ },
103
+ {
104
+ "$ref": "#/$defs/case"
105
+ }
106
+ ]
107
+ }
108
+ },
109
+ "reason": {
110
+ "type": "string",
111
+ "minLength": 1
112
+ },
113
+ "group": {
114
+ "type": "object",
115
+ "required": [
116
+ "group"
117
+ ],
118
+ "additionalProperties": false,
119
+ "properties": {
120
+ "group": {
121
+ "type": "string",
122
+ "minLength": 1,
123
+ "description": "グループの名前(入れ子の深さが階層になる)"
124
+ },
125
+ "description": {
126
+ "type": "string"
127
+ },
128
+ "state": {
129
+ "type": "string",
130
+ "description": "中のケースの既定の状態"
131
+ },
132
+ "marks": {
133
+ "type": "array",
134
+ "items": {
135
+ "type": "string"
136
+ }
137
+ },
138
+ "meta": {
139
+ "type": "object"
140
+ },
141
+ "skip": {
142
+ "$ref": "#/$defs/reason",
143
+ "description": "中のケースすべてを、この理由でスキップする"
144
+ },
145
+ "xfail": {
146
+ "$ref": "#/$defs/reason",
147
+ "description": "中のケースすべてが、この理由で失敗すると分かっている"
148
+ },
149
+ "cases": {
150
+ "$ref": "#/$defs/items"
151
+ }
152
+ }
153
+ },
154
+ "case": {
155
+ "type": "object",
156
+ "required": [
157
+ "name"
158
+ ],
159
+ "additionalProperties": false,
160
+ "oneOf": [
161
+ {
162
+ "required": [
163
+ "command"
164
+ ]
165
+ },
166
+ {
167
+ "required": [
168
+ "call"
169
+ ]
170
+ },
171
+ {
172
+ "required": [
173
+ "request"
174
+ ]
175
+ }
176
+ ],
177
+ "properties": {
178
+ "name": {
179
+ "type": "string",
180
+ "pattern": "^[\\w.\\-]+$",
181
+ "description": "ケース名(期待出力のファイル名になる)"
182
+ },
183
+ "description": {
184
+ "type": "string",
185
+ "description": "確認する内容"
186
+ },
187
+ "command": {
188
+ "description": "CLI として実行する引数。リストで書くと区切らずにそのまま渡す(Windows のパス向け)",
189
+ "oneOf": [
190
+ {
191
+ "type": "string"
192
+ },
193
+ {
194
+ "type": "array",
195
+ "items": {
196
+ "type": "string"
197
+ }
198
+ }
199
+ ]
200
+ },
201
+ "stdin": {
202
+ "type": "string"
203
+ },
204
+ "timeout": {
205
+ "type": "number",
206
+ "exclusiveMinimum": 0
207
+ },
208
+ "call": {
209
+ "type": "string",
210
+ "pattern": "^[^:\\s]+:[^:\\s]+$",
211
+ "description": "モジュール:関数"
212
+ },
213
+ "args": {
214
+ "type": "array"
215
+ },
216
+ "kwargs": {
217
+ "type": "object"
218
+ },
219
+ "request": {
220
+ "$ref": "#/$defs/request"
221
+ },
222
+ "state": {
223
+ "type": "string"
224
+ },
225
+ "setup": {
226
+ "$ref": "#/$defs/setup"
227
+ },
228
+ "observe": {
229
+ "type": "array",
230
+ "items": {
231
+ "type": "string"
232
+ },
233
+ "description": "実行後に取り出す対象(files など)"
234
+ },
235
+ "expect": {
236
+ "$ref": "#/$defs/expect"
237
+ },
238
+ "marks": {
239
+ "type": "array",
240
+ "items": {
241
+ "type": "string"
242
+ }
243
+ },
244
+ "meta": {
245
+ "type": "object"
246
+ },
247
+ "skip": {
248
+ "$ref": "#/$defs/reason"
249
+ },
250
+ "xfail": {
251
+ "$ref": "#/$defs/reason"
252
+ },
253
+ "golden": {
254
+ "type": "string",
255
+ "description": "期待出力のファイル名(省略時は <name>.txt)"
256
+ }
257
+ }
258
+ },
259
+ "setup": {
260
+ "type": [
261
+ "object",
262
+ "null"
263
+ ],
264
+ "description": "前提条件。files は作業ディレクトリに置くファイル、それ以外は pytest_casebook_setup に渡す",
265
+ "properties": {
266
+ "files": {
267
+ "type": "object",
268
+ "additionalProperties": {
269
+ "type": "string"
270
+ }
271
+ }
272
+ }
273
+ },
274
+ "headers": {
275
+ "type": "object",
276
+ "additionalProperties": {
277
+ "type": [
278
+ "string",
279
+ "number",
280
+ "boolean"
281
+ ]
282
+ }
283
+ },
284
+ "request": {
285
+ "oneOf": [
286
+ {
287
+ "type": "string",
288
+ "pattern": "^\\s*(([Gg][Ee][Tt]|[Pp][Oo][Ss][Tt]|[Pp][Uu][Tt]|[Pp][Aa][Tt][Cc][Hh]|[Dd][Ee][Ll][Ee][Tt][Ee]|[Hh][Ee][Aa][Dd]|[Oo][Pp][Tt][Ii][Oo][Nn][Ss])\\s+)?/\\S*\\s*$",
289
+ "description": "\"メソッド パス\"(メソッドを省くと GET)"
290
+ },
291
+ {
292
+ "type": "object",
293
+ "required": [
294
+ "path"
295
+ ],
296
+ "additionalProperties": false,
297
+ "not": {
298
+ "required": [
299
+ "json",
300
+ "body"
301
+ ]
302
+ },
303
+ "properties": {
304
+ "method": {
305
+ "type": "string",
306
+ "pattern": "^([Gg][Ee][Tt]|[Pp][Oo][Ss][Tt]|[Pp][Uu][Tt]|[Pp][Aa][Tt][Cc][Hh]|[Dd][Ee][Ll][Ee][Tt][Ee]|[Hh][Ee][Aa][Dd]|[Oo][Pp][Tt][Ii][Oo][Nn][Ss])$"
307
+ },
308
+ "path": {
309
+ "type": "string",
310
+ "pattern": "^/"
311
+ },
312
+ "query": {
313
+ "type": "object"
314
+ },
315
+ "headers": {
316
+ "$ref": "#/$defs/headers"
317
+ },
318
+ "json": {
319
+ "description": "JSON の本文"
320
+ },
321
+ "body": {
322
+ "type": "string",
323
+ "description": "JSON 以外の本文"
324
+ }
325
+ }
326
+ }
327
+ ]
328
+ },
329
+ "text": {
330
+ "description": "文字列なら完全一致、{contains: ...} なら含む、{regex: ...} なら正規表現",
331
+ "oneOf": [
332
+ {
333
+ "type": "string"
334
+ },
335
+ {
336
+ "type": "object",
337
+ "minProperties": 1,
338
+ "maxProperties": 1,
339
+ "additionalProperties": false,
340
+ "properties": {
341
+ "contains": {
342
+ "type": "string"
343
+ },
344
+ "regex": {
345
+ "type": "string"
346
+ },
347
+ "$contains": {
348
+ "type": "string"
349
+ },
350
+ "$regex": {
351
+ "type": "string"
352
+ }
353
+ }
354
+ }
355
+ ]
356
+ },
357
+ "expect": {
358
+ "type": "object",
359
+ "minProperties": 1,
360
+ "description": "期待結果。ここに書いた項目だけを比べる。下の項目以外のキーは observe の対象(db.users など)",
361
+ "not": {
362
+ "required": [
363
+ "return",
364
+ "raises"
365
+ ]
366
+ },
367
+ "properties": {
368
+ "exit_code": {
369
+ "type": "integer"
370
+ },
371
+ "stdout": {
372
+ "$ref": "#/$defs/text"
373
+ },
374
+ "stderr": {
375
+ "$ref": "#/$defs/text"
376
+ },
377
+ "return": {
378
+ "description": "辞書は書いたキーだけ、リストは件数と各要素を比べる。$contains・$regex・$unordered・$includes が使える"
379
+ },
380
+ "raises": {
381
+ "$ref": "#/$defs/text"
382
+ },
383
+ "status": {
384
+ "type": "integer"
385
+ },
386
+ "json": {
387
+ "description": "return と同じ比べ方"
388
+ },
389
+ "body": {
390
+ "$ref": "#/$defs/text"
391
+ },
392
+ "headers": {
393
+ "type": "object",
394
+ "additionalProperties": {
395
+ "$ref": "#/$defs/text"
396
+ }
397
+ }
398
+ }
399
+ }
400
+ }
401
+ }
@@ -0,0 +1,208 @@
1
+ """
2
+ YAML の `expect:` に書いた期待値と、実際の結果を比べる。
3
+
4
+ - 文字列(stdout / stderr / raises / body): そのまま書けば完全一致(末尾の改行は無視)、
5
+ `{contains: ...}` なら含まれていればよい、`{regex: ...}` なら正規表現に合えばよい
6
+ - 値(return / json / observe の対象): 辞書は書いたキーだけを比べ、リストは件数と各要素を比べ、
7
+ それ以外は完全一致。値の中では、`$` で始まる1つのキーだけの辞書を「比べ方」として扱う
8
+ (データのキーと紛れないように `$` を付ける)
9
+
10
+ - `{$contains: ...}` / `{$regex: ...}`: 文字列が含む / 正規表現に合う
11
+ - `{$unordered: [...]}`: 順序を問わず、件数と各要素が一致する
12
+ - `{$includes: [...]}`: 書いた要素がすべて含まれていればよい(ほかの要素があってもよい)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import difflib
18
+ import re
19
+ from collections.abc import Callable
20
+ from typing import Any
21
+
22
+ from .messages import MESSAGES
23
+ from .serialize import compact
24
+
25
+ # 値の中に書ける比べ方
26
+ VALUE_MATCHERS = ("$contains", "$regex", "$unordered", "$includes")
27
+
28
+
29
+ def _show(value: Any) -> str:
30
+ return compact(value)
31
+
32
+
33
+ def value_matcher(expected: Any) -> tuple[str, Any] | None:
34
+ """`{$contains: ...}` のような比べ方なら `(名前, 中身)` を返す。そうでなければ None。"""
35
+ if isinstance(expected, dict) and len(expected) == 1:
36
+ ((key, body),) = expected.items()
37
+ if isinstance(key, str) and key.startswith("$"):
38
+ return key, body
39
+ return None
40
+
41
+
42
+ def match_text(expected: Any, actual: str, label: str, lang: str = "ja") -> list[str]:
43
+ """
44
+ 文字列の期待値と比べ、違いの説明のリストを返す(一致すれば空)。
45
+
46
+ expected は YAML を読むときに確かめ済みのもの(文字列か {contains} / {regex})。
47
+ """
48
+ m = MESSAGES[lang]
49
+ actual = actual.rstrip("\n")
50
+ if isinstance(expected, str):
51
+ expected = expected.rstrip("\n")
52
+ if expected == actual:
53
+ return []
54
+ if "\n" in expected or "\n" in actual:
55
+ # 末尾の改行は取り除いてあるので、行は改行なしで比べて、あとで改行でつなぐ
56
+ # (keepends で比べると、最後の行どうしが1行につながってしまう)
57
+ diff = "\n".join(
58
+ difflib.unified_diff(
59
+ expected.splitlines(),
60
+ actual.splitlines(),
61
+ fromfile=m["diff_expected"].format(label=label),
62
+ tofile=m["diff_actual"].format(label=label),
63
+ lineterm="",
64
+ )
65
+ )
66
+ return [m["text_differs"].format(label=label) + f"\n{diff.rstrip()}"]
67
+ return [_mismatch(m, label, expected, actual)]
68
+
69
+ ((matcher, pattern),) = expected.items()
70
+ matcher = matcher.lstrip("$") # {contains} と {$contains} は同じ
71
+ if matcher == "contains" and pattern not in actual:
72
+ return [
73
+ m["not_contained"].format(label=label, pattern=_show(pattern), actual=_show(actual))
74
+ ]
75
+ if matcher == "regex" and not re.search(pattern, actual, re.MULTILINE):
76
+ return [
77
+ m["no_regex_match"].format(label=label, pattern=_show(pattern), actual=_show(actual))
78
+ ]
79
+ return []
80
+
81
+
82
+ def _mismatch(m: dict[str, str], label: str, expected: Any, actual: Any) -> str:
83
+ return m["mismatch"].format(label=label, expected=_show(expected), actual=_show(actual))
84
+
85
+
86
+ def match_value(expected: Any, actual: Any, label: str, lang: str = "ja") -> list[str]:
87
+ """
88
+ 値の期待値と比べ、違いの説明のリストを返す(一致すれば空)。
89
+
90
+ :param expected: YAML に書いた期待値
91
+ :param actual: 実際の値(`to_jsonable` で変換し、正規化したもの)
92
+ :param label: 説明に使う場所の名前(例: `db.users[1].name`)
93
+ :param lang: 説明の言語(`ja` か `en`)
94
+ :return: 違いの説明のリスト(一致すれば空)
95
+ """
96
+ m = MESSAGES[lang]
97
+ matcher = value_matcher(expected)
98
+ if matcher is not None:
99
+ return _match_with(matcher[0], matcher[1], actual, label, lang)
100
+ if isinstance(expected, dict):
101
+ if not isinstance(actual, dict):
102
+ return [_mismatch(m, label, expected, actual)]
103
+ problems: list[str] = []
104
+ for key, value in expected.items():
105
+ if key not in actual:
106
+ problems.append(m["missing_key"].format(label=f"{label}.{key}"))
107
+ else:
108
+ problems += match_value(value, actual[key], f"{label}.{key}", lang)
109
+ return problems
110
+ if isinstance(expected, list):
111
+ if not isinstance(actual, list):
112
+ return [_mismatch(m, label, expected, actual)]
113
+ problems = []
114
+ if len(expected) != len(actual):
115
+ problems.append(
116
+ m["count_differs"].format(label=label, expected=len(expected), actual=len(actual))
117
+ )
118
+ for i, (e, a) in enumerate(zip(expected, actual, strict=False)):
119
+ problems += match_value(e, a, f"{label}[{i}]", lang)
120
+ return problems
121
+ if isinstance(expected, str) and isinstance(actual, str):
122
+ # ファイルの内容などは末尾の改行の有無を問わない(YAML の書き方で変わりやすいため)
123
+ if expected.rstrip("\n") != actual.rstrip("\n"):
124
+ return [_mismatch(m, label, expected, actual)]
125
+ return []
126
+ # True == 1 にならないよう、真偽値は型も比べる
127
+ if isinstance(expected, bool) != isinstance(actual, bool) or expected != actual:
128
+ return [_mismatch(m, label, expected, actual)]
129
+ return []
130
+
131
+
132
+ def _match_with(name: str, body: Any, actual: Any, label: str, lang: str) -> list[str]:
133
+ """`$` で始まる比べ方で比べる。"""
134
+ m = MESSAGES[lang]
135
+ if name in ("$contains", "$regex"):
136
+ if not isinstance(actual, str):
137
+ return [m["not_string"].format(label=label, actual=_show(actual))]
138
+ return match_text({name: body}, actual, label, lang)
139
+ if not isinstance(actual, list):
140
+ return [m["not_list"].format(label=label, actual=_show(actual))]
141
+ if name == "$unordered" and len(body) != len(actual):
142
+ return [m["count_differs"].format(label=label, expected=len(body), actual=len(actual))]
143
+ # 期待の要素と実際の要素を、1対1で、できるだけ多く組み合わせる(二部グラフの最大マッチング)。
144
+ # 先頭から順に貪欲に組むと、{type: A} のような広い条件が先にあるときに、
145
+ # 後ろの細かい条件に合うはずの要素を取ってしまい、書く順番によって失敗してしまう
146
+ fits = [
147
+ [j for j, value in enumerate(actual) if not match_value(item, value, label, lang)]
148
+ for item in body
149
+ ]
150
+ owner: dict[int, int] = {} # 実際の要素 -> 組んだ期待の要素
151
+
152
+ def assign(i: int, seen: set[int]) -> bool:
153
+ """期待の i 番目を、空いている(または付け替えられる)実際の要素に割り当てる。"""
154
+ for j in fits[i]:
155
+ if j in seen:
156
+ continue
157
+ seen.add(j)
158
+ if j not in owner or assign(owner[j], seen):
159
+ owner[j] = i
160
+ return True
161
+ return False
162
+
163
+ matched = {i for i in range(len(body)) if assign(i, set())}
164
+ return [
165
+ m["no_element"].format(label=label, item=_show(item), index=i)
166
+ for i, item in enumerate(body)
167
+ if i not in matched
168
+ ]
169
+
170
+
171
+ def find_matcher_errors(value: Any, label: str) -> list[str]:
172
+ """期待値の中の `$` で始まる比べ方の書き間違いを探す(YAML を読むときに確かめる)。"""
173
+ matcher = value_matcher(value)
174
+ if matcher is not None:
175
+ name, body = matcher
176
+ if name not in VALUE_MATCHERS:
177
+ known = " / ".join(VALUE_MATCHERS)
178
+ return [f"{label}: 知らない比べ方です: {name}(使えるのは {known})"]
179
+ if name in ("$contains", "$regex") and not isinstance(body, str):
180
+ return [f"{label}: {name} には文字列を書いてください"]
181
+ if name == "$regex":
182
+ try:
183
+ re.compile(body)
184
+ except re.error as e:
185
+ return [f"{label}: 正規表現の誤りです: {e}"]
186
+ if name in ("$unordered", "$includes"):
187
+ if not isinstance(body, list):
188
+ return [f"{label}: {name} にはリストを書いてください"]
189
+ return [
190
+ err for i, v in enumerate(body) for err in find_matcher_errors(v, f"{label}[{i}]")
191
+ ]
192
+ return []
193
+ if isinstance(value, dict):
194
+ return [err for k, v in value.items() for err in find_matcher_errors(v, f"{label}.{k}")]
195
+ if isinstance(value, list):
196
+ return [err for i, v in enumerate(value) for err in find_matcher_errors(v, f"{label}[{i}]")]
197
+ return []
198
+
199
+
200
+ def map_strings(value: Any, func: Callable[[str], str]) -> Any:
201
+ """JSON にできる値の中の文字列すべてに func を当てる(正規化に使う)。"""
202
+ if isinstance(value, str):
203
+ return func(value)
204
+ if isinstance(value, list):
205
+ return [map_strings(v, func) for v in value]
206
+ if isinstance(value, dict):
207
+ return {k: map_strings(v, func) for k, v in value.items()}
208
+ return value