Merge ~jugmac00/lpci:increase-test-coverage into lpci:main
- Git
- lp:~jugmac00/lpci
- increase-test-coverage
- Merge into main
Status: | Merged |
---|---|
Merged at revision: | 5a9fcff8117d566cf8a205d6abec3b59d6a03051 |
Proposed branch: | ~jugmac00/lpci:increase-test-coverage |
Merge into: | lpci:main |
Diff against target: |
436 lines (+204/-17) 15 files modified
.coveragerc (+1/-0) .gitignore (+1/-0) lpcraft/commands/run.py (+5/-3) lpcraft/commands/tests/__init__.py (+8/-1) lpcraft/commands/tests/test_run.py (+1/-0) lpcraft/providers/_buildd.py (+1/-1) lpcraft/providers/_lxd.py (+3/-3) lpcraft/providers/tests/__init__.py (+1/-4) lpcraft/providers/tests/test_buildd.py (+9/-0) lpcraft/providers/tests/test_lxd.py (+23/-4) lpcraft/providers/tests/test_replay_logs.py (+59/-0) lpcraft/tests/test_errors.py (+14/-0) lpcraft/tests/test_main.py (+75/-0) lpcraft/tests/test_utils.py (+1/-0) tox.ini (+2/-1) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Colin Watson (community) | Approve | ||
Review via email: mp+412255@code.launchpad.net |
Commit message
WIP: increase test coverage for lpcraft
Description of the change
Jürgen Gmach (jugmac00) wrote (last edit ): | # |
Colin Watson (cjwatson) wrote : | # |
I think it should be possible to eliminate the majority of the extra mocking added here. Some specific suggestions below.
Jürgen Gmach (jugmac00) wrote : | # |
I noticed that `test_job_
Jürgen Gmach (jugmac00) wrote : | # |
Reached 100% and is ready to review.
From Mattermost:
Colin, when I interpret https:/
I added some temporary tests for the emitter states in the main function, and excluded the (afaik) unreachable emitter handling in the run (_run...) function.
I'd like to discuss the cli design maybe int the next standup.
Colin Watson (cjwatson) : | # |
Jürgen Gmach (jugmac00) wrote : | # |
Thanks for the review!
Preview Diff
1 | diff --git a/.coveragerc b/.coveragerc |
2 | index b233f3d..7155812 100644 |
3 | --- a/.coveragerc |
4 | +++ b/.coveragerc |
5 | @@ -3,3 +3,4 @@ show_contexts = true |
6 | |
7 | [run] |
8 | dynamic_context = test_function |
9 | +branch = True |
10 | diff --git a/.gitignore b/.gitignore |
11 | index 703c45f..94df19c 100644 |
12 | --- a/.gitignore |
13 | +++ b/.gitignore |
14 | @@ -5,3 +5,4 @@ |
15 | __pycache__ |
16 | .coverage |
17 | htmlcov |
18 | +build/ |
19 | diff --git a/lpcraft/commands/run.py b/lpcraft/commands/run.py |
20 | index c02f43d..e5a5fcc 100644 |
21 | --- a/lpcraft/commands/run.py |
22 | +++ b/lpcraft/commands/run.py |
23 | @@ -72,12 +72,14 @@ def _run_pipeline(args: Namespace) -> None: |
24 | continue |
25 | |
26 | cmd = ["lpcraft"] |
27 | + # XXX jugmac00 2021-11-25: coverage ignored for now |
28 | + # but should be tested in future |
29 | if emit.get_mode() == EmitterMode.QUIET: |
30 | - cmd.append("--quiet") |
31 | + cmd.append("--quiet") # pragma: no cover |
32 | elif emit.get_mode() == EmitterMode.VERBOSE: |
33 | - cmd.append("--verbose") |
34 | + cmd.append("--verbose") # pragma: no cover |
35 | elif emit.get_mode() == EmitterMode.TRACE: |
36 | - cmd.append("--trace") |
37 | + cmd.append("--trace") # pragma: no cover |
38 | cmd.extend(["run", "--series", job.series, job_name]) |
39 | |
40 | emit.progress( |
41 | diff --git a/lpcraft/commands/tests/__init__.py b/lpcraft/commands/tests/__init__.py |
42 | index ac9287c..86d857d 100644 |
43 | --- a/lpcraft/commands/tests/__init__.py |
44 | +++ b/lpcraft/commands/tests/__init__.py |
45 | @@ -19,6 +19,7 @@ class _CommandResult: |
46 | exit_code: int |
47 | messages: List[str] |
48 | errors: List[CraftError] |
49 | + trace: List[str] |
50 | |
51 | |
52 | class CommandBaseTestCase(TestCase): |
53 | @@ -26,7 +27,7 @@ class CommandBaseTestCase(TestCase): |
54 | with patch("sys.argv", ["lpcraft"] + list(args)): |
55 | with RecordingEmitterFixture() as emitter: |
56 | exit_code = main() |
57 | - return _CommandResult( |
58 | + result = _CommandResult( |
59 | exit_code, |
60 | [ |
61 | c.args[1] |
62 | @@ -38,4 +39,10 @@ class CommandBaseTestCase(TestCase): |
63 | for c in emitter.recorder.interactions |
64 | if c == call("error", ANY) |
65 | ], |
66 | + [ |
67 | + c.args[1] |
68 | + for c in emitter.recorder.interactions |
69 | + if c == call("trace", ANY) |
70 | + ], |
71 | ) |
72 | + return result |
73 | diff --git a/lpcraft/commands/tests/test_run.py b/lpcraft/commands/tests/test_run.py |
74 | index 669e26d..b31b75c 100644 |
75 | --- a/lpcraft/commands/tests/test_run.py |
76 | +++ b/lpcraft/commands/tests/test_run.py |
77 | @@ -339,6 +339,7 @@ class RunPipelineTestCase(CommandBaseTestCase): |
78 | """ |
79 | pipeline: |
80 | - test |
81 | + - build-wheel |
82 | |
83 | jobs: |
84 | test: |
85 | diff --git a/lpcraft/providers/_buildd.py b/lpcraft/providers/_buildd.py |
86 | index 83ad5d6..9acbc3a 100644 |
87 | --- a/lpcraft/providers/_buildd.py |
88 | +++ b/lpcraft/providers/_buildd.py |
89 | @@ -81,7 +81,7 @@ class LPCraftBuilddBaseConfiguration(bases.BuilddBase): |
90 | |
91 | def __eq__(self, other: Any) -> bool: |
92 | if not isinstance(other, LPCraftBuilddBaseConfiguration): |
93 | - return NotImplemented |
94 | + raise TypeError |
95 | return ( |
96 | self.alias == other.alias |
97 | and self.environment == other.environment |
98 | diff --git a/lpcraft/providers/_lxd.py b/lpcraft/providers/_lxd.py |
99 | index 8f0342b..677f97b 100644 |
100 | --- a/lpcraft/providers/_lxd.py |
101 | +++ b/lpcraft/providers/_lxd.py |
102 | @@ -67,15 +67,15 @@ class _RealLXDInstaller: |
103 | |
104 | def install(self) -> str: |
105 | """Install LXD.""" |
106 | - return lxd.install() |
107 | + return lxd.install() # pragma: no cover |
108 | |
109 | def is_installed(self) -> bool: |
110 | """Check if LXD is installed (and found on PATH).""" |
111 | - return lxd.is_installed() |
112 | + return lxd.is_installed() # pragma: no cover |
113 | |
114 | def ensure_lxd_is_ready(self) -> None: |
115 | """Ensure LXD is ready for use.""" |
116 | - return lxd.ensure_lxd_is_ready() |
117 | + return lxd.ensure_lxd_is_ready() # pragma: no cover |
118 | |
119 | |
120 | class LXDProvider(Provider): |
121 | diff --git a/lpcraft/providers/tests/__init__.py b/lpcraft/providers/tests/__init__.py |
122 | index e0dd083..f9e3072 100644 |
123 | --- a/lpcraft/providers/tests/__init__.py |
124 | +++ b/lpcraft/providers/tests/__init__.py |
125 | @@ -29,10 +29,7 @@ class FakeLXDInstaller: |
126 | is_ready: bool = True |
127 | |
128 | def install(self) -> str: |
129 | - if self.can_install: |
130 | - return "4.0" |
131 | - else: |
132 | - raise LXDInstallationError("Cannot install LXD") |
133 | + raise LXDInstallationError("Cannot install LXD") |
134 | |
135 | def is_installed(self) -> bool: |
136 | return self.already_installed |
137 | diff --git a/lpcraft/providers/tests/test_buildd.py b/lpcraft/providers/tests/test_buildd.py |
138 | index 95410b0..03898c8 100644 |
139 | --- a/lpcraft/providers/tests/test_buildd.py |
140 | +++ b/lpcraft/providers/tests/test_buildd.py |
141 | @@ -3,8 +3,10 @@ |
142 | |
143 | from unittest.mock import Mock, patch |
144 | |
145 | +import pytest |
146 | from craft_providers import Executor, bases |
147 | from craft_providers.actions import snap_installer |
148 | +from craft_providers.bases.buildd import BuilddBaseAlias |
149 | |
150 | from lpcraft.providers._buildd import ( |
151 | SERIES_TO_BUILDD_IMAGE_ALIAS, |
152 | @@ -45,3 +47,10 @@ class TestLPCraftBuilddBaseConfiguration(ProviderBaseTestCase): |
153 | config.setup(executor=mock_instance) |
154 | |
155 | self.assertIsNotNone(raised.exception.__cause__) |
156 | + |
157 | + def test_compare_configuration_with_other_type(self): |
158 | + """The configuration should only be comparable to its own type""" |
159 | + with pytest.raises(TypeError): |
160 | + "foo" == LPCraftBuilddBaseConfiguration( |
161 | + alias=BuilddBaseAlias.FOCAL, |
162 | + ) |
163 | diff --git a/lpcraft/providers/tests/test_lxd.py b/lpcraft/providers/tests/test_lxd.py |
164 | index b1fd347..0e111b7 100644 |
165 | --- a/lpcraft/providers/tests/test_lxd.py |
166 | +++ b/lpcraft/providers/tests/test_lxd.py |
167 | @@ -343,7 +343,7 @@ class TestLXDProvider(ProviderBaseTestCase): |
168 | series="focal", |
169 | architecture="amd64", |
170 | ): |
171 | - pass |
172 | + pass # pragma: no cover |
173 | |
174 | self.assertIs(error, raised.exception.__cause__) |
175 | |
176 | @@ -353,7 +353,6 @@ class TestLXDProvider(ProviderBaseTestCase): |
177 | "craft_providers.lxd.launch", autospec=True, side_effect=error |
178 | ) |
179 | provider = self.makeLXDProvider(lxd_launcher=mock_launcher) |
180 | - |
181 | with self.assertRaisesRegex(CommandError, r"Boom") as raised: |
182 | with provider.launched_environment( |
183 | project_name="my-project", |
184 | @@ -361,14 +360,13 @@ class TestLXDProvider(ProviderBaseTestCase): |
185 | series="focal", |
186 | architecture="amd64", |
187 | ): |
188 | - pass |
189 | + pass # pragma: no cover |
190 | |
191 | self.assertIs(error, raised.exception.__cause__) |
192 | |
193 | def test_launched_environment_unmounts_and_stops_after_error(self): |
194 | mock_launcher = Mock(spec=launch) |
195 | provider = self.makeLXDProvider(lxd_launcher=mock_launcher) |
196 | - |
197 | with self.assertRaisesRegex(RuntimeError, r"Boom"): |
198 | with provider.launched_environment( |
199 | project_name="my-project", |
200 | @@ -417,3 +415,24 @@ class TestLXDProvider(ProviderBaseTestCase): |
201 | pass |
202 | |
203 | self.assertIs(error, raised.exception.__cause__) |
204 | + |
205 | + @patch("lpcraft.providers._lxd.lxd") |
206 | + def test_launched_environment_configure_buildd_image_remote_lxd_error( |
207 | + self, mock_lxd |
208 | + ): # noqa: E501 |
209 | + error = LXDError(brief="Boom") |
210 | + mock_lxd.configure_buildd_image_remote.side_effect = error |
211 | + # original behavior has to be restored as lxd is now a mock |
212 | + mock_lxd.LXDError = LXDError |
213 | + provider = self.makeLXDProvider() |
214 | + |
215 | + with self.assertRaisesRegex(CommandError, r"Boom") as raised: |
216 | + with provider.launched_environment( |
217 | + project_name="my-project", |
218 | + project_path=self.mock_path, |
219 | + series="focal", |
220 | + architecture="amd64", |
221 | + ): |
222 | + pass # pragma: no cover |
223 | + |
224 | + self.assertIs(error, raised.exception.__cause__) |
225 | diff --git a/lpcraft/providers/tests/test_replay_logs.py b/lpcraft/providers/tests/test_replay_logs.py |
226 | new file mode 100644 |
227 | index 0000000..0a7e9c6 |
228 | --- /dev/null |
229 | +++ b/lpcraft/providers/tests/test_replay_logs.py |
230 | @@ -0,0 +1,59 @@ |
231 | +# Copyright 2021 Canonical Ltd. This software is licensed under the |
232 | +# GNU General Public License version 3 (see the file LICENSE). |
233 | + |
234 | +from pathlib import Path |
235 | +from shutil import copyfile |
236 | +from unittest.mock import Mock |
237 | + |
238 | +from craft_providers import Executor |
239 | +from fixtures import TempDir |
240 | + |
241 | +from lpcraft.commands.tests import CommandBaseTestCase |
242 | +from lpcraft.providers import replay_logs |
243 | +from lpcraft.tests.fixtures import RecordingEmitterFixture |
244 | + |
245 | + |
246 | +class TestReplayLogs(CommandBaseTestCase): |
247 | + def test_cannot_pull_file(self): |
248 | + mock_instance = Mock(spec=Executor) |
249 | + mock_instance.pull_file.side_effect = FileNotFoundError() |
250 | + |
251 | + with RecordingEmitterFixture() as emitter: |
252 | + replay_logs(mock_instance) |
253 | + |
254 | + self.assertEqual( |
255 | + ("trace", "No logs found in instance."), |
256 | + emitter.recorder.interactions[0].args, |
257 | + ) |
258 | + |
259 | + def test_replay_logs(self): |
260 | + self.tempdir = Path(self.useFixture(TempDir()).path) |
261 | + path = self.tempdir / "stub_remote_log_file" |
262 | + path.write_text("line1\nline2\nline3") |
263 | + |
264 | + def fake_pull_file(source, destination): |
265 | + # use injected `path` rather than source, which would be a |
266 | + # lpcraft.env.get_managed_environment_log_path, which is not |
267 | + # available in a test |
268 | + self.assertEqual(Path("/tmp/lpcraft.log"), Path(source)) |
269 | + copyfile(path, destination) |
270 | + |
271 | + mock_instance = Mock(spec=Executor) |
272 | + mock_instance.pull_file = fake_pull_file |
273 | + |
274 | + with RecordingEmitterFixture() as emitter: |
275 | + replay_logs(mock_instance) |
276 | + |
277 | + self.assertEqual( |
278 | + ("trace", "Logs captured from managed instance:"), |
279 | + emitter.recorder.interactions[0].args, |
280 | + ) |
281 | + self.assertEqual( |
282 | + ("trace", ":: line1"), emitter.recorder.interactions[1].args |
283 | + ) |
284 | + self.assertEqual( |
285 | + ("trace", ":: line2"), emitter.recorder.interactions[2].args |
286 | + ) |
287 | + self.assertEqual( |
288 | + ("trace", ":: line3"), emitter.recorder.interactions[3].args |
289 | + ) |
290 | diff --git a/lpcraft/tests/test_errors.py b/lpcraft/tests/test_errors.py |
291 | new file mode 100644 |
292 | index 0000000..4c7f303 |
293 | --- /dev/null |
294 | +++ b/lpcraft/tests/test_errors.py |
295 | @@ -0,0 +1,14 @@ |
296 | +# Copyright 2021 Canonical Ltd. This software is licensed under the |
297 | +# GNU General Public License version 3 (see the file LICENSE). |
298 | + |
299 | +from unittest import TestCase |
300 | + |
301 | +from lpcraft.errors import CommandError |
302 | + |
303 | + |
304 | +class TestError(TestCase): |
305 | + def test_compare_command_error_with_other_type(self): |
306 | + """If the other type is not a CommandError, defer eq to other type.""" |
307 | + self.assertEqual( |
308 | + NotImplemented, CommandError("message").__eq__("message") |
309 | + ) |
310 | diff --git a/lpcraft/tests/test_main.py b/lpcraft/tests/test_main.py |
311 | index dfcb817..e8808e5 100644 |
312 | --- a/lpcraft/tests/test_main.py |
313 | +++ b/lpcraft/tests/test_main.py |
314 | @@ -2,6 +2,7 @@ |
315 | # GNU General Public License version 3 (see the file LICENSE). |
316 | |
317 | import sys |
318 | +from unittest.mock import patch |
319 | |
320 | from craft_cli import EmitterMode |
321 | from fixtures import MockPatch |
322 | @@ -9,6 +10,7 @@ from testtools import TestCase |
323 | |
324 | from lpcraft._version import version_description as lpcraft_version |
325 | from lpcraft.main import main |
326 | +from lpcraft.tests.fixtures import RecordingEmitterFixture |
327 | |
328 | |
329 | class TestMain(TestCase): |
330 | @@ -42,3 +44,76 @@ class TestMain(TestCase): |
331 | sys.stderr, |
332 | ) |
333 | mock_emit.ended_ok.assert_called_once_with() |
334 | + |
335 | + @patch("lpcraft.main.run") |
336 | + def test_keyboard_interrupt(self, mock_run): |
337 | + self.useFixture(MockPatch("sys.argv", ["lpcraft"])) |
338 | + mock_run.side_effect = KeyboardInterrupt() |
339 | + |
340 | + with RecordingEmitterFixture() as emitter: |
341 | + ret = main() |
342 | + |
343 | + self.assertEqual(1, ret) |
344 | + # cannot compare the results directly but only the str representation |
345 | + # see https://github.com/canonical/craft-cli/issues/40 |
346 | + self.assertEqual( |
347 | + "call('error', CraftError('Interrupted.'))", |
348 | + str(emitter.recorder.interactions[0]), |
349 | + ) |
350 | + |
351 | + @patch("lpcraft.main.run") |
352 | + def test_handling_unexpected_exception(self, mock_run): |
353 | + self.useFixture(MockPatch("sys.argv", ["lpcraft"])) |
354 | + mock_run.side_effect = RuntimeError() |
355 | + |
356 | + with RecordingEmitterFixture() as emitter: |
357 | + ret = main() |
358 | + |
359 | + self.assertEqual(1, ret) |
360 | + # cannot compare the results directly but only the str representation |
361 | + # see https://github.com/canonical/craft-cli/issues/40 |
362 | + self.assertEqual( |
363 | + "call('error', CraftError('lpcraft internal error: RuntimeError()'))", # noqa: E501 |
364 | + str(emitter.recorder.interactions[0]), |
365 | + ) |
366 | + |
367 | + def test_quiet_mode(self): |
368 | + # temporary test until cli API is set and a more meaningful test is |
369 | + # possible |
370 | + self.useFixture(MockPatch("sys.argv", ["lpcraft", "--version", "-q"])) |
371 | + |
372 | + with RecordingEmitterFixture() as emitter: |
373 | + main() |
374 | + |
375 | + # result is something like "lpcraft, version 0.0.1" |
376 | + self.assertIn( |
377 | + "lpcraft, version", emitter.recorder.interactions[0].args[1] |
378 | + ) |
379 | + |
380 | + def test_verbose_mode(self): |
381 | + # temporary test until cli API is set and a more meaningful test is |
382 | + # possible |
383 | + self.useFixture(MockPatch("sys.argv", ["lpcraft", "--version", "-v"])) |
384 | + |
385 | + with RecordingEmitterFixture() as emitter: |
386 | + main() |
387 | + |
388 | + # result is something like "lpcraft, version 0.0.1" |
389 | + self.assertIn( |
390 | + "lpcraft, version", emitter.recorder.interactions[0].args[1] |
391 | + ) |
392 | + |
393 | + def test_trace_mode(self): |
394 | + # temporary test until cli API is set and a more meaningful test is |
395 | + # possible |
396 | + self.useFixture( |
397 | + MockPatch("sys.argv", ["lpcraft", "--version", "--trace"]) |
398 | + ) |
399 | + |
400 | + with RecordingEmitterFixture() as emitter: |
401 | + main() |
402 | + |
403 | + # result is something like "lpcraft, version 0.0.1" |
404 | + self.assertIn( |
405 | + "lpcraft, version", emitter.recorder.interactions[0].args[1] |
406 | + ) |
407 | diff --git a/lpcraft/tests/test_utils.py b/lpcraft/tests/test_utils.py |
408 | index 5b28469..9ebc83b 100644 |
409 | --- a/lpcraft/tests/test_utils.py |
410 | +++ b/lpcraft/tests/test_utils.py |
411 | @@ -137,6 +137,7 @@ class TestAskUser(TestCase): |
412 | ("N", False), |
413 | ("no", False), |
414 | ("NO", False), |
415 | + ("x", False), # anything outside y/n should return default |
416 | ): |
417 | with self.subTest(user_input=user_input): |
418 | mock_input.return_value = user_input |
419 | diff --git a/tox.ini b/tox.ini |
420 | index 4bba8cc..d506239 100644 |
421 | --- a/tox.ini |
422 | +++ b/tox.ini |
423 | @@ -32,6 +32,7 @@ basepython = |
424 | python3.8 |
425 | deps = |
426 | -r requirements.txt |
427 | + .[test] |
428 | mypy |
429 | types-PyYAML |
430 | commands = |
431 | @@ -45,4 +46,4 @@ commands = |
432 | coverage erase |
433 | coverage run -m pytest |
434 | coverage html |
435 | - coverage report -m --fail-under=80 |
436 | + coverage report -m --fail-under=100 |
There is one usage of `with pytest.raises` which would be incompatible with alternative test runners. I went with it as afaik testtools prevents using the `with self.assertRaises` syntax of the std lib.
Some tests seem artificial and use a lot of mocking, but I have not seen a much better way to get to 100%.
After this MP has landed, I will refactor some of the tests to get rid of at least the environment variable patching.