Skip to content

Commit f1a3fef

Browse files
committed
Add unittest session handling, --snapshot-update support
Add test-session-level features for raw unittest framework: * Support --snapshot-update command line option * Issue "SnapshotTest summary" at end of test run * Remove unused snapshots when in update mode This also adds snapshottest-augmented versions of the various ways to run unittest: * `python -m snapshottest ...` parallels `python -m unittest ...` (via module-level `__main__.py`). * `snapshottest.main()` wraps `unittest.main()`. * `SnapshotTestRunnerMixin` can be used with other unittest TestRunner classes. Closes syrusakbary#51
1 parent 48528b7 commit f1a3fef

File tree

5 files changed

+188
-8
lines changed

5 files changed

+188
-8
lines changed

README.md

+12-1
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,21 @@ class APITestCase(TestCase):
3333
self.assertMatchSnapshot(my_gpg_response, 'gpg_response')
3434
```
3535

36-
If you want to update the snapshots automatically you can use the `nosetests --snapshot-update`.
36+
You'll also need to let your test runner know about snapshottest,
37+
to summarize the snapshot results and handle removing unused snapshots:
38+
* If your code calls `unittest.main()`, replace that with `snapshottest.main()`
39+
* If you run `python -m unittest ...`, switch to `python -m snapshottest ...`
40+
* If you use nose, snapshottest automatically loads a nose plugin
41+
that handles this for you
42+
* Or if you have a custom unittest TestRunner, add
43+
`snapshottest.unittest.SnapshotTestRunnerMixin` (see its docstring for more info)
44+
45+
To generate new snapshots, add `--snapshot-update` to your usual test command line
46+
(e.g., `python -m snapshottest ... --snapshot-update` or `nosetests --snapshot-update`).
3747

3848
Check the [Unittest example](https://github.com/syrusakbary/snapshottest/tree/master/examples/unittest).
3949

50+
4051
## Usage with pytest
4152

4253
```python

examples/unittest/test_demo.py

+9-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import unittest
21
import snapshottest
32

43

@@ -8,6 +7,12 @@ def api_client_get(url):
87
}
98

109

10+
# Use snapshottest.TestCase in place of unittest.TestCase
11+
# where you want to run snapshot tests.
12+
#
13+
# (You can also mix it into any subclass of unittest.TestCase:
14+
# class TestDemo(snapshottest.TestCase, MyCustomTestCase):
15+
# ...)
1116
class TestDemo(snapshottest.TestCase):
1217
def setUp(self):
1318
pass
@@ -18,4 +23,6 @@ def test_api_me(self):
1823

1924

2025
if __name__ == "__main__":
21-
unittest.main()
26+
# Replace unittest.main() with snapshottest's version:
27+
# unittest.main()
28+
snapshottest.main()

snapshottest/__init__.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from .snapshot import Snapshot
22
from .generic_repr import GenericRepr
33
from .module import assert_match_snapshot
4-
from .unittest import TestCase
4+
from .unittest import TestCase, main
55

66

7-
__all__ = ["Snapshot", "GenericRepr", "assert_match_snapshot", "TestCase"]
7+
__all__ = ["Snapshot", "GenericRepr", "assert_match_snapshot", "TestCase", "main"]

snapshottest/__main__.py

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
"""Main entry point (for unittest with snapshottest support)"""
2+
3+
# This is here to support invoking snapshottest-augmented unittest via
4+
# `python -m snapshottest ...` (paralleling unittest's own `python -m unittest ...`).
5+
# It's copied almost directly from unittest.__main__.
6+
7+
import sys
8+
9+
if sys.argv[0].endswith("__main__.py"):
10+
import os.path
11+
12+
# We change sys.argv[0] to make help message more useful
13+
# use executable without path, unquoted
14+
executable = os.path.basename(sys.executable)
15+
sys.argv[0] = executable + " -m snapshottest"
16+
del os
17+
18+
from .unittest import main
19+
20+
main(module=None)

snapshottest/unittest.py

+145-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import unittest
21
import inspect
2+
import sys
3+
import unittest
34

4-
from .module import SnapshotModule, SnapshotTest
55
from .diff import PrettyDiff
6-
from .reporting import diff_report
6+
from .module import SnapshotModule, SnapshotTest
7+
from .reporting import diff_report, reporting_lines
78

89

910
class UnitTestSnapshotTest(SnapshotTest):
@@ -36,6 +37,10 @@ def test_name(self):
3637
# Inspired by https://gist.github.com/twolfson/13f5f5784f67fd49b245
3738
class TestCase(unittest.TestCase):
3839

40+
# Whether snapshots should be updated, for all unittest-derived frameworks.
41+
# Set (perhaps circuitously) in runner init from the --snapshot-update
42+
# command line option. (.unittest.TestCase.snapshot_should_update is the
43+
# equivalent of pytest's config.option.snapshot_update.)
3944
snapshot_should_update = False
4045

4146
@classmethod
@@ -99,3 +104,140 @@ def assert_match_snapshot(self, value, name=""):
99104
self._snapshot.assert_match(value, name=name)
100105

101106
assertMatchSnapshot = assert_match_snapshot
107+
108+
109+
def output_snapshottest_summary(stream=None, testing_cli=None):
110+
"""
111+
Outputs a summary of snapshot tests for the session, if any.
112+
113+
Call at the end of a test session to write results summary
114+
to stream (default sys.stderr). If no snapshot tests were run,
115+
outputs nothing.
116+
117+
testing_cli (default from sys.argv) should be the string command
118+
line that invokes the tests, and is used to explain how to update
119+
snapshots.
120+
121+
(This is the equivalent of .pytest.SnapshotSession.display,
122+
for unittest-derived frameworks.)
123+
"""
124+
# TODO: Call this to replace near-duplicate code in .django and .nose.
125+
126+
if not SnapshotModule.has_snapshots():
127+
return
128+
129+
if stream is None:
130+
# This follows unittest.TextTestRunner, which by default uses sys.stderr
131+
# for test status and summary info (not sys.stdout).
132+
stream = sys.stderr
133+
if testing_cli is None:
134+
# We can't really recover the exact command line formatted for the user's shell
135+
# (quoting, etc.), but this should be close enough to get the point across.
136+
testing_cli = " ".join(sys.argv)
137+
138+
separator1 = "=" * 70
139+
separator2 = "-" * 70
140+
141+
print(separator1, file=stream)
142+
print("SnapshotTest summary", file=stream)
143+
print(separator2, file=stream)
144+
for line in reporting_lines(testing_cli):
145+
print(line, file=stream)
146+
print(separator1, file=stream)
147+
148+
149+
def finalize_snapshots():
150+
"""
151+
Call at the end of a unittest session to delete unused snapshots.
152+
153+
(This deletes the data needed for SnapshotModule.total_unvisited_snapshots.
154+
Complete any reporting before calling this function.)
155+
"""
156+
# TODO: this is duplicated in four places (with varying "should_update" conditions).
157+
# Move it into shared code for snapshot sessions (which is currently implemented
158+
# as classmethods on SnapshotModule).
159+
if TestCase.snapshot_should_update:
160+
for module in SnapshotModule.get_modules():
161+
module.delete_unvisited()
162+
module.save()
163+
164+
165+
class SnapshotTestRunnerMixin:
166+
"""
167+
A mixin for a unittest TestRunner that adds snapshottest session handling.
168+
169+
Note: a TestRunner is not responsible for command line options. If you are
170+
adding snapshottest support to other unittest-derived frameworks, you must
171+
also arrange to set snapshottest.unittest.TestCase.snapshot_should_update
172+
when the user requests --snapshot-update.
173+
"""
174+
175+
def run(self, test):
176+
result = super().run(test)
177+
self.report_snapshottest_summary()
178+
finalize_snapshots()
179+
return result
180+
181+
def report_snapshottest_summary(self):
182+
"""Report a summary of snapshottest results for the session"""
183+
if hasattr(self, "stream"):
184+
# Mixed into a unittest.TextTestRunner or similar (with an output stream)
185+
output_snapshottest_summary(self.stream)
186+
else:
187+
# Mixed into some sort of graphical frontend, probably
188+
raise NotImplementedError(
189+
"Non-text TestRunner with SnapshotTestRunnerMixin"
190+
" must implement report_snapshottest_summary"
191+
)
192+
193+
194+
class SnapshotTextTestRunner(SnapshotTestRunnerMixin, unittest.TextTestRunner):
195+
"""
196+
Version of unittest.TextTestRunner that adds snapshottest session handling.
197+
"""
198+
199+
pass
200+
201+
202+
class SnapshotTestProgram(unittest.TestProgram):
203+
"""
204+
Augmented implementation of unittest.main that adds --snapshot-update
205+
command line option, and that ensures testRunner includes snapshottest
206+
session handling.
207+
"""
208+
209+
def __init__(self, *args, testRunner=None, **kwargs):
210+
# (For simplicity, we only allow testRunner as a kwarg.)
211+
if testRunner is None:
212+
testRunner = SnapshotTextTestRunner
213+
# Verify the testRunner includes snapshot session handling.
214+
# "The testRunner argument can either be a test runner class
215+
# or an already created instance of it."
216+
if not issubclass(testRunner, SnapshotTestRunnerMixin) and not isinstance(
217+
testRunner, SnapshotTestRunnerMixin
218+
):
219+
raise TypeError(
220+
"snapshottest testRunner must include SnapshotTestRunnerMixin"
221+
)
222+
223+
self._snapshot_update = False
224+
super().__init__(*args, testRunner=testRunner, **kwargs)
225+
226+
def _getParentArgParser(self):
227+
# (Yes, this is hooking a private method. Sorry.
228+
# unittest.TestProgram isn't really designed to be extended.)
229+
parser = super()._getParentArgParser()
230+
parser.add_argument(
231+
"--snapshot-update",
232+
dest="_snapshot_update",
233+
action="store_true",
234+
help="Update snapshottest snapshots",
235+
)
236+
return parser
237+
238+
def runTests(self):
239+
TestCase.snapshot_should_update = self._snapshot_update
240+
super().runTests()
241+
242+
243+
main = SnapshotTestProgram

0 commit comments

Comments
 (0)