Skip to content

Commit

Permalink
Part of #3928 [Testing Examples] Add First Class Python Support (#4166)
Browse files Browse the repository at this point in the history
# 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!!!
  • Loading branch information
himanshumahajan138 authored Jan 7, 2025
1 parent f962cce commit b60ad32
Show file tree
Hide file tree
Showing 19 changed files with 400 additions and 1 deletion.
1 change: 1 addition & 0 deletions docs/modules/ROOT/nav.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
18 changes: 18 additions & 0 deletions docs/modules/ROOT/pages/pythonlib/testing.adoc
Original file line number Diff line number Diff line change
@@ -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[]
1 change: 1 addition & 0 deletions example/package.mill
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
7 changes: 7 additions & 0 deletions example/pythonlib/testing/1-test-suite/bar/src/bar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class Bar:
def main(self):
return "Hello World"


if __name__ == "__main__":
print(Bar().main())
21 changes: 21 additions & 0 deletions example/pythonlib/testing/1-test-suite/bar/test/src/test_bar.py
Original file line number Diff line number Diff line change
@@ -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()
137 changes: 137 additions & 0 deletions example/pythonlib/testing/1-test-suite/build.mill
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions example/pythonlib/testing/1-test-suite/foo/src/foo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class Foo:
def main(self):
return "Hello World"


if __name__ == "__main__":
print(Foo().main())
24 changes: 24 additions & 0 deletions example/pythonlib/testing/1-test-suite/foo/test/src/test_foo.py
Original file line number Diff line number Diff line change
@@ -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()
6 changes: 6 additions & 0 deletions example/pythonlib/testing/2-test-deps/bar/src/bar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
def get_value() -> int:
return 123


if __name__ == "__main__":
print(get_value())
9 changes: 9 additions & 0 deletions example/pythonlib/testing/2-test-deps/bar/test/src/test.py
Original file line number Diff line number Diff line change
@@ -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]))
Original file line number Diff line number Diff line change
@@ -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}"
68 changes: 68 additions & 0 deletions example/pythonlib/testing/2-test-deps/build.mill
Original file line number Diff line number Diff line change
@@ -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
<h1><XYZ></h1>

> ./mill bar.run
123

*/
10 changes: 10 additions & 0 deletions example/pythonlib/testing/2-test-deps/foo/src/foo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from jinja2 import Template


def get_value(text: str) -> str:
template = Template("<h1>{{ text | safe }}</h1>")
return template.render(text=text)


if __name__ == "__main__":
print(get_value(text="<XYZ>"))
12 changes: 12 additions & 0 deletions example/pythonlib/testing/2-test-deps/foo/test/src/test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import unittest
from markupsafe import escape
from foo import get_value # type: ignore
from test_utils import BarTestUtils # type: ignore


class TestScript(unittest.TestCase):
def test_equal_string(self) -> None:
escaped_text = escape("<XYZ>")
BarTestUtils().bar_assert_equals(
f"<h1>{escaped_text}</h1>", get_value(text="&lt;XYZ&gt;")
)
47 changes: 47 additions & 0 deletions example/pythonlib/testing/3-integration-suite/build.mill
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// You can also define test suites with different names other than `test`. For example,
// the build below defines a test suite with the name `itest`, in addition
// to that named `test`.

package build
import mill._, pythonlib._

object foo extends PythonModule {

def mainScript = Task.Source { millSourcePath / "src/foo.py" }

object test extends PythonTests with TestModule.Unittest

object itest extends PythonTests with TestModule.Unittest

}

// These two test modules will expect their sources to be in their respective `foo/test` and
// `foo/itest` folder respectively.

/** Usage

> ./mill foo.test # run Unit test suite
test_hello (test.TestScript...) ... ok
test_world (test.TestScript...) ... ok
...Ran 2 tests...
...OK...

> ./mill foo.itest # run Integration test suite
...test_hello_world (test.TestScript...) ... ok
...Ran 1 test...
...OK...

> ./mill 'foo.{test,itest}' # run both test suites
...test_hello (test.TestScript...)...ok...
...test_world (test.TestScript...)...ok...
...Ran 2 tests...
...test_hello_world (test.TestScript...)...ok...
...Ran 1 test...
...OK...

> ./mill __.itest.testCached # run all integration test suites
...test_hello_world (test.TestScript...) ... ok
...Ran 1 test...
...OK...

*/
Loading

0 comments on commit b60ad32

Please sign in to comment.