From b60ad32b1711c5d5cdebded04e8e5e86a4a8a65d Mon Sep 17 00:00:00 2001 From: Himanshu Mahajan <83700343+himanshumahajan138@users.noreply.github.com> Date: Wed, 8 Jan 2025 03:15:31 +0530 Subject: [PATCH] Part of #3928 [Testing Examples] Add First Class Python Support (#4166) # Pull Request Adds First Class Python Support [Testing Examples] Part of #3928 ## Description Testing Examples for Python `1-test-suite`, `2-test-deps`, `3-integration-suite` Examples ## Related Issues - Link to related issue #3928. ## Checklist - [x] 1-test-suite - [x] 2-test-deps - [x] 3-integration-suite - [x] Updated Documentation ## Status WIP & Require Review!!! --- docs/modules/ROOT/nav.adoc | 1 + .../modules/ROOT/pages/pythonlib/testing.adoc | 18 +++ example/package.mill | 1 + .../testing/1-test-suite/bar/src/bar.py | 7 + .../1-test-suite/bar/test/src/test_bar.py | 21 +++ .../pythonlib/testing/1-test-suite/build.mill | 137 ++++++++++++++++++ .../testing/1-test-suite/foo/src/foo.py | 7 + .../1-test-suite/foo/test/src/test_foo.py | 24 +++ .../testing/2-test-deps/bar/src/bar.py | 6 + .../testing/2-test-deps/bar/test/src/test.py | 9 ++ .../2-test-deps/bar/test/src/test_utils.py | 4 + .../pythonlib/testing/2-test-deps/build.mill | 68 +++++++++ .../testing/2-test-deps/foo/src/foo.py | 10 ++ .../testing/2-test-deps/foo/test/src/test.py | 12 ++ .../testing/3-integration-suite/build.mill | 47 ++++++ .../3-integration-suite/foo/itest/src/test.py | 8 + .../3-integration-suite/foo/src/foo.py | 7 + .../3-integration-suite/foo/test/src/test.py | 12 ++ pythonlib/src/mill/pythonlib/TestModule.scala | 2 +- 19 files changed, 400 insertions(+), 1 deletion(-) create mode 100644 docs/modules/ROOT/pages/pythonlib/testing.adoc create mode 100644 example/pythonlib/testing/1-test-suite/bar/src/bar.py create mode 100644 example/pythonlib/testing/1-test-suite/bar/test/src/test_bar.py create mode 100644 example/pythonlib/testing/1-test-suite/build.mill create mode 100644 example/pythonlib/testing/1-test-suite/foo/src/foo.py create mode 100644 example/pythonlib/testing/1-test-suite/foo/test/src/test_foo.py create mode 100644 example/pythonlib/testing/2-test-deps/bar/src/bar.py create mode 100644 example/pythonlib/testing/2-test-deps/bar/test/src/test.py create mode 100644 example/pythonlib/testing/2-test-deps/bar/test/src/test_utils.py create mode 100644 example/pythonlib/testing/2-test-deps/build.mill create mode 100644 example/pythonlib/testing/2-test-deps/foo/src/foo.py create mode 100644 example/pythonlib/testing/2-test-deps/foo/test/src/test.py create mode 100644 example/pythonlib/testing/3-integration-suite/build.mill create mode 100644 example/pythonlib/testing/3-integration-suite/foo/itest/src/test.py create mode 100644 example/pythonlib/testing/3-integration-suite/foo/src/foo.py create mode 100644 example/pythonlib/testing/3-integration-suite/foo/test/src/test.py diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index 3892ffab420..d9e659bbd4a 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -38,6 +38,7 @@ *** xref:pythonlib/dependencies.adoc[] *** xref:pythonlib/publishing.adoc[] *** xref:pythonlib/web-examples.adoc[] +*** xref:pythonlib/testing.adoc[] ** xref:javascriptlib/intro.adoc[] *** xref:javascriptlib/dependencies.adoc[] *** xref:javascriptlib/module-config.adoc[] diff --git a/docs/modules/ROOT/pages/pythonlib/testing.adoc b/docs/modules/ROOT/pages/pythonlib/testing.adoc new file mode 100644 index 00000000000..64118b3f103 --- /dev/null +++ b/docs/modules/ROOT/pages/pythonlib/testing.adoc @@ -0,0 +1,18 @@ += Testing Python Projects +:page-aliases: Testing_Python_Projects.adoc + +include::partial$gtag-config.adoc[] + +This page will discuss topics around defining and running Python tests using the Mill build tool + +== Defining Unit Test Suites + +include::partial$example/pythonlib/testing/1-test-suite.adoc[] + +== Test Dependencies + +include::partial$example/pythonlib/testing/2-test-deps.adoc[] + +== Defining Integration Test Suites + +include::partial$example/pythonlib/testing/3-integration-suite.adoc[] diff --git a/example/package.mill b/example/package.mill index 374a484adce..c6426dfcaa1 100644 --- a/example/package.mill +++ b/example/package.mill @@ -73,6 +73,7 @@ object `package` extends RootModule with Module { object publishing extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "publishing")) object module extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "module")) object web extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "web")) + object testing extends Cross[ExampleCrossModule](build.listIn(millSourcePath / "testing")) } object cli extends Module{ diff --git a/example/pythonlib/testing/1-test-suite/bar/src/bar.py b/example/pythonlib/testing/1-test-suite/bar/src/bar.py new file mode 100644 index 00000000000..541885669dd --- /dev/null +++ b/example/pythonlib/testing/1-test-suite/bar/src/bar.py @@ -0,0 +1,7 @@ +class Bar: + def main(self): + return "Hello World" + + +if __name__ == "__main__": + print(Bar().main()) diff --git a/example/pythonlib/testing/1-test-suite/bar/test/src/test_bar.py b/example/pythonlib/testing/1-test-suite/bar/test/src/test_bar.py new file mode 100644 index 00000000000..f5c1d8f528b --- /dev/null +++ b/example/pythonlib/testing/1-test-suite/bar/test/src/test_bar.py @@ -0,0 +1,21 @@ +from unittest.mock import MagicMock +from bar import Bar # type: ignore + + +def test_hello(): + result = Bar().main() + assert result.startswith("Hello"), "Result does not start with 'Hello'" + + +def test_world(): + result = Bar().main() + assert result.endswith("World"), "Result does not end with 'World'" + + +def test_mock(): + mock_bar = MagicMock(spec=Bar) + mock_bar.main.return_value = "Hello Mockito World" + result = mock_bar.main() + + assert (result == "Hello Mockito World"), "Mocked main() did not return expected value" + mock_bar.main.assert_called_once() diff --git a/example/pythonlib/testing/1-test-suite/build.mill b/example/pythonlib/testing/1-test-suite/build.mill new file mode 100644 index 00000000000..a752c07d005 --- /dev/null +++ b/example/pythonlib/testing/1-test-suite/build.mill @@ -0,0 +1,137 @@ +package build +import mill._, pythonlib._ + +object foo extends PythonModule { + def mainScript = Task.Source { millSourcePath / "src/foo.py" } + object test extends PythonTests with TestModule.Unittest { + def mainScript = Task.Source { millSourcePath / "src/test_foo.py" } + } +} + +// This build defines a single module with a test suite, configured to use +// "Unittest" as the testing framework. Test suites are themselves ``PythonModule``s, +// nested within the enclosing module, and have all the normal tasks +// available to run, but with an additional `.test` task +// that runs the tests. You can also run the test suite directly, in which case +// it will run the `.test` task as the default task for that module. + +/** Usage + +> ./mill foo.run +Hello World + +> ./mill foo.test.test +test_hello (test_foo.TestScript...) ... ok +test_mock (test_foo.TestScript...) ... ok +test_world (test_foo.TestScript...) ... ok +...Ran 3 tests... +OK + +> ./mill foo.test # same as above, `.test` is the default task for the `test` module +...Ran 3 tests... +OK + +> ./mill foo.test test_foo.TestScript.test_mock # explicitly select the test class you wish to run +test_mock (test_foo.TestScript...) ... ok +...Ran 1 test... +OK + +*/ + +// For convenience, you can also use one of the predefined test frameworks: +// +// * `TestModule.Pytest`, using https://pytest.org[Pytest] +// * `TestModule.Unittest`, using https://docs.python.org/3/library/unittest.html[Unittest] +// +// Each testing framework has their own flags and configuration options that are +// documented on their respective websites, so please see the links above for more +// details on how to use each one from the command line. + +object bar extends PythonModule { + def mainScript = Task.Source { millSourcePath / "src/bar.py" } + object test extends PythonTests with TestModule.Pytest +} + +/** Usage + +> ./mill bar.test +...test_bar.py::test_hello PASSED... +...test_bar.py::test_world PASSED... +...test_bar.py::test_mock PASSED... +...3 passed... + +*/ + +// You can also select multiple test suites in one command using Mill's +// xref:cli/query-syntax.adoc[Task Query Syntax] + +/** Usage + +> ./mill __.test +test_hello (test_foo.TestScript...) ... ok +test_mock (test_foo.TestScript...) ... ok +test_world (test_foo.TestScript...) ... ok +...Ran 3 tests... +OK +...test_bar.py::test_hello PASSED... +...test_bar.py::test_world PASSED... +...test_bar.py::test_mock PASSED... +...3 passed... + +*/ + +// Mill provides multiple ways of running tests + +/** Usage + +> ./mill foo.test +test_hello (test_foo.TestScript...) ... ok +test_mock (test_foo.TestScript...) ... ok +test_world (test_foo.TestScript...) ... ok +...Ran 3 tests... +OK + +*/ + +// * `foo.test`: runs tests in a subprocess in an empty `sandbox/` folder. This is short +// for `foo.test.test`, as `test` is the default task for ``TestModule``s. + +/** Usage + +> ./mill foo.test.testCached +test_hello (test_foo.TestScript...) ... ok +test_mock (test_foo.TestScript...) ... ok +test_world (test_foo.TestScript...) ... ok +...Ran 3 tests... +OK + +*/ + +// * `foo.test.testCached`: runs the tests in an empty `sandbox/` folder and caches the results +// if successful. This can be handy if you are you working on some upstream modules and only +// want to run downstream tests which are affected: using `testCached`, downstream tests which +// are not affected will be cached after the first run and not re-run unless you change some +// file upstream of them. +// +// It is common to run tests with xref:cli/flags.adoc#_watch_w[-w/--watch] +// enabled, so that once you save a file to disk the selected tests get re-run. +// +// NOTE: Mill runs tests with the working directory set to an empty +// xref:depth/sandboxing.adoc[sandbox/ folder] by default. +// Additional paths can be provided to test via `forkEnv`. See +// xref:pythonlib/module-config.adoc#_pythonpath_and_filesystem_resources[Pythonpath and Filesystem Resources] +// for more details. +// +// If you want to pass any arguments to the test framework, you can pass them after +// `foo.test` in the command line. e.g. https://pytest.org[Pytest] +// lets you pass in a selector to decide which test to run, which in Mill would be: + +/** Usage + +> ./mill bar.test bar/test/src/test_bar.py::test_mock # explicitly select the test class you wish to run +...test_bar.py::test_mock PASSED... +...1 passed... + +*/ + +// This command only runs the `test_mock` test case in the `bar.test` test suite class. diff --git a/example/pythonlib/testing/1-test-suite/foo/src/foo.py b/example/pythonlib/testing/1-test-suite/foo/src/foo.py new file mode 100644 index 00000000000..ec233ce24ef --- /dev/null +++ b/example/pythonlib/testing/1-test-suite/foo/src/foo.py @@ -0,0 +1,7 @@ +class Foo: + def main(self): + return "Hello World" + + +if __name__ == "__main__": + print(Foo().main()) diff --git a/example/pythonlib/testing/1-test-suite/foo/test/src/test_foo.py b/example/pythonlib/testing/1-test-suite/foo/test/src/test_foo.py new file mode 100644 index 00000000000..8333c2fc45b --- /dev/null +++ b/example/pythonlib/testing/1-test-suite/foo/test/src/test_foo.py @@ -0,0 +1,24 @@ +import unittest +from unittest.mock import MagicMock +from foo import Foo # type: ignore + + +class TestScript(unittest.TestCase): + def test_hello(self) -> None: + result = Foo().main() + self.assertTrue(result.startswith("Hello")) + + def test_world(self) -> None: + result = Foo().main() + self.assertTrue(result.endswith("World")) + + def test_mock(self): + mock_foo = MagicMock(spec=Foo) + mock_foo.main.return_value = "Hello Mockito World" + result = mock_foo.main() + self.assertEqual( + result, + "Hello Mockito World", + "Mocked hello() did not return expected value", + ) + mock_foo.main.assert_called_once() diff --git a/example/pythonlib/testing/2-test-deps/bar/src/bar.py b/example/pythonlib/testing/2-test-deps/bar/src/bar.py new file mode 100644 index 00000000000..c641eae06e3 --- /dev/null +++ b/example/pythonlib/testing/2-test-deps/bar/src/bar.py @@ -0,0 +1,6 @@ +def get_value() -> int: + return 123 + + +if __name__ == "__main__": + print(get_value()) diff --git a/example/pythonlib/testing/2-test-deps/bar/test/src/test.py b/example/pythonlib/testing/2-test-deps/bar/test/src/test.py new file mode 100644 index 00000000000..2ec58b3b551 --- /dev/null +++ b/example/pythonlib/testing/2-test-deps/bar/test/src/test.py @@ -0,0 +1,9 @@ +import unittest +import statistics +from bar import get_value # type: ignore +from test_utils import BarTestUtils # type: ignore + + +class TestScript(unittest.TestCase): + def test_mean(self) -> None: + BarTestUtils().bar_assert_equals(get_value(), statistics.mean([122, 124])) diff --git a/example/pythonlib/testing/2-test-deps/bar/test/src/test_utils.py b/example/pythonlib/testing/2-test-deps/bar/test/src/test_utils.py new file mode 100644 index 00000000000..e0490be54cc --- /dev/null +++ b/example/pythonlib/testing/2-test-deps/bar/test/src/test_utils.py @@ -0,0 +1,4 @@ +class BarTestUtils: + def bar_assert_equals(self, a, b): + print("Using BarTestUtils.bar_assert_equals") + assert a == b, f"Expected {b} but got {a}" diff --git a/example/pythonlib/testing/2-test-deps/build.mill b/example/pythonlib/testing/2-test-deps/build.mill new file mode 100644 index 00000000000..f78cdcd6b20 --- /dev/null +++ b/example/pythonlib/testing/2-test-deps/build.mill @@ -0,0 +1,68 @@ +// You can use `pythonDeps` to declare dependencies in test modules, +// and test modules can use their `moduleDeps` to also depend on each other + +package build +import mill._, pythonlib._ + +object foo extends PythonModule { + + def mainScript = Task.Source { millSourcePath / "src/foo.py" } + + def moduleDeps = Seq(bar) + + def pythonDeps = Seq("jinja2==3.1.4") + + object test extends PythonTests with TestModule.Unittest { + def moduleDeps = super.moduleDeps ++ Seq(bar.test) + + def pythonDeps = Seq("MarkupSafe==3.0.2") + + } + +} + +object bar extends PythonModule { + + def mainScript = Task.Source { millSourcePath / "src/bar.py" } + + object test extends PythonTests with TestModule.Unittest + +} + +// In this example, not only does `foo` depend on `bar`, but we also make +// `foo.test` depend on `bar.test`. +// +// ```graphviz +// digraph G { +// rankdir=LR +// node [shape=box width=0 height=0] +// "bar" -> "bar.test" -> "foo.test" +// "bar" -> "foo" -> "foo.test" +// } +// ``` +// +// That lets `foo.test` make use of the +// `BarTestUtils` class that `bar.test` defines, allowing us to re-use this +// test helper throughout multiple modules test suites + +/** Usage + +> ./mill foo.test +...Using BarTestUtils.bar_assert_equals... +...test_equal_string (test.TestScript...)...ok... +...Ran 1 test... +...OK... + +> ./mill bar.test +...Using BarTestUtils.bar_assert_equals... +...test_mean (test.TestScript...)...ok... +...Ran 1 test... +...OK... + +> ./mill foo.run +