Merge ~jugmac00/lpci:increase-test-coverage into lpci:main

Proposed by Jürgen Gmach
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)
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

To post a comment you must log in.
Revision history for this message
Jürgen Gmach (jugmac00) wrote (last edit ):

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.

Revision history for this message
Colin Watson (cjwatson) wrote :

I think it should be possible to eliminate the majority of the extra mocking added here. Some specific suggestions below.

review: Needs Fixing
Revision history for this message
Jürgen Gmach (jugmac00) wrote :

I noticed that `test_job_not_defined_for_host_architecture` was actually not testing what the name proposed, but it was testing that jobs are skipped when they are not used in a pipeline. I fixed the test to do what it was meant to do - but I did not create a new one to test jobs are skipped if they are not defined in a pipeline. If I should do so, please just say so.

Revision history for this message
Jürgen Gmach (jugmac00) wrote :

Reached 100% and is ready to review.

From Mattermost:
Colin, when I interpret https://pastebin.canonical.com/p/R7wpc8FYj8/ correctly, than verbose/quiet/trace cannot be used together with the version or the run command ( https://pastebin.canonical.com/p/fVNFbBJZxv/ ) - which would mean that the code at the bottom ( https://pastebin.canonical.com/p/TGDbkxWbMQ/ ) can't be reached, as it is called from run. Ok - lpcraft version and lpcraft --version act differently :confused: https://pastebin.canonical.com/p/wWhx3CNpcG/

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.

Revision history for this message
Colin Watson (cjwatson) :
review: Approve
Revision history for this message
Jürgen Gmach (jugmac00) wrote :

Thanks for the review!

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/.coveragerc b/.coveragerc
2index 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
10diff --git a/.gitignore b/.gitignore
11index 703c45f..94df19c 100644
12--- a/.gitignore
13+++ b/.gitignore
14@@ -5,3 +5,4 @@
15 __pycache__
16 .coverage
17 htmlcov
18+build/
19diff --git a/lpcraft/commands/run.py b/lpcraft/commands/run.py
20index 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(
41diff --git a/lpcraft/commands/tests/__init__.py b/lpcraft/commands/tests/__init__.py
42index 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
73diff --git a/lpcraft/commands/tests/test_run.py b/lpcraft/commands/tests/test_run.py
74index 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:
85diff --git a/lpcraft/providers/_buildd.py b/lpcraft/providers/_buildd.py
86index 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
98diff --git a/lpcraft/providers/_lxd.py b/lpcraft/providers/_lxd.py
99index 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):
121diff --git a/lpcraft/providers/tests/__init__.py b/lpcraft/providers/tests/__init__.py
122index 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
137diff --git a/lpcraft/providers/tests/test_buildd.py b/lpcraft/providers/tests/test_buildd.py
138index 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+ )
163diff --git a/lpcraft/providers/tests/test_lxd.py b/lpcraft/providers/tests/test_lxd.py
164index 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__)
225diff --git a/lpcraft/providers/tests/test_replay_logs.py b/lpcraft/providers/tests/test_replay_logs.py
226new file mode 100644
227index 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+ )
290diff --git a/lpcraft/tests/test_errors.py b/lpcraft/tests/test_errors.py
291new file mode 100644
292index 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+ )
310diff --git a/lpcraft/tests/test_main.py b/lpcraft/tests/test_main.py
311index 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+ )
407diff --git a/lpcraft/tests/test_utils.py b/lpcraft/tests/test_utils.py
408index 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
419diff --git a/tox.ini b/tox.ini
420index 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

Subscribers

People subscribed via source and target branches