Skip to content

Commit fee21ae

Browse files
authored
PyTest test runner for Basilisp (#195)
* PyTest test runner for Basilisp [WIP] * Functioning test runner! 🎉 * Fix linter silencing comment * Fix six at 1.10.0 for coverage tox env * Reset collected tests for each file * Test the test runner * Slightly update the tests; still broken * Stop removing basilisp.core in random tests * Return a map from test-runners
1 parent 2f6c160 commit fee21ae

File tree

12 files changed

+325
-3
lines changed

12 files changed

+325
-3
lines changed

Pipfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ name = "pypi"
77
twine = "*"
88
tox = "*"
99
tox-pyenv = "*"
10+
pytest-pycharm = "*"
1011

1112
[packages]
1213
astor = "*"
@@ -16,6 +17,7 @@ pyfunctional = "*"
1617
pyrsistent = "*"
1718
python-dateutil = "*"
1819
basilisp = {editable = true, path = "."}
20+
pytest = "*"
1921

2022
[requires]
2123
python_version = "3.6"

Pipfile.lock

Lines changed: 42 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ def run(self):
105105

106106
entry_points={
107107
'console_scripts': ['basilisp=basilisp.cli:cli'],
108+
'pytest11': ['basilisp_test_runner=basilisp.testrunner']
108109
},
109110
install_requires=REQUIRED,
110111
extras_require=EXTRAS,

src/basilisp/cli.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import types
66

77
import click
8+
import pytest
89

910
import basilisp.compiler as compiler
1011
import basilisp.lang.runtime as runtime
@@ -103,5 +104,12 @@ def run(file_or_code, code, in_ns):
103104
print(compiler.lrepr(eval_file(file_or_code, ctx, ns.module)))
104105

105106

107+
@cli.command(short_help='run tests in a Basilisp project')
108+
@click.argument('args', nargs=-1)
109+
def test(args):
110+
"""Run tests in a Basilisp project."""
111+
pytest.main(args=list(args))
112+
113+
106114
if __name__ == "__main__":
107115
cli()

src/basilisp/test.lpy

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
(ns basilisp.test)
2+
3+
(def ^:private collected-tests
4+
(atom []))
5+
6+
(def ^:private current-ns
7+
(atom nil))
8+
9+
(defn ^:private add-test!
10+
"Add the test named by test-var to the test suite for ns."
11+
[ns test-var]
12+
(swap! collected-tests conj test-var))
13+
14+
(defmacro is
15+
"Assert that expr is true. Must appear inside of a deftest form."
16+
([expr]
17+
`(is ~expr (str "Test failure: " ~expr)))
18+
([expr msg]
19+
(let [line-no (:basilisp.reader/line (meta &form))
20+
21+
eq-sym `=
22+
23+
deconstruct? (and (list? expr) (= eq-sym (first expr)))
24+
25+
expected (if deconstruct?
26+
(second expr)
27+
expr)
28+
actual (if deconstruct?
29+
(nth expr 2)
30+
expr)]
31+
`(let [computed# ~expr
32+
actual# ~actual
33+
expected# ~expected]
34+
(when-not computed#
35+
;; Collect test failures in the `failures` atom created
36+
;; by `deftest`.
37+
(swap! ~'failures
38+
conj
39+
[~msg
40+
{:test-name ~'test-name
41+
:test-section ~'test-section
42+
:expr (quote ~expr)
43+
:line ~line-no
44+
:actual actual#
45+
:expected expected#}]))))))
46+
47+
(defmacro testing
48+
"Wrapper for test cases to provide additional messaging and context
49+
around the test or group of tests contained inside. Must appear inside
50+
of a deftest form."
51+
[msg & body]
52+
`(let [~'test-section (if ~'test-section
53+
(str ~'test-section " :: " ~msg)
54+
~msg)]
55+
~@body))
56+
57+
(defmacro deftest
58+
"Define a new test function. Assertions can be made with the is macro.
59+
Group tests with the testing macro.
60+
61+
Tests defined by deftest will be run by default by the PyTest test
62+
runner using Basilisp's builtin PyTest hook."
63+
[name-sym & body]
64+
(let [test-name-sym (with-meta name-sym {:test true})
65+
test-name-str (name test-name-sym)]
66+
`(do
67+
(defn ~test-name-sym
68+
[]
69+
(let [~'test-name ~test-name-str
70+
~'test-section nil
71+
~'failures (atom [])]
72+
~@body
73+
{:failures (deref ~'failures)}))
74+
75+
(add-test! *ns* (var ~test-name-sym))
76+
(reset! current-ns *ns*))))

src/basilisp/testrunner.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import importlib
2+
from typing import Optional, Callable
3+
4+
import pytest
5+
6+
import basilisp.lang.exception as lexc
7+
import basilisp.lang.keyword as kw
8+
import basilisp.lang.map as lmap
9+
import basilisp.lang.runtime as runtime
10+
import basilisp.lang.symbol as sym
11+
import basilisp.lang.vector as vec
12+
import basilisp.main as basilisp
13+
from basilisp.lang.util import lrepr
14+
from basilisp.util import Maybe
15+
16+
basilisp.init()
17+
importlib.import_module('basilisp.test')
18+
19+
_COLLECTED_TESTS_SYM = sym.symbol('collected-tests', ns='basilisp.test')
20+
_CURRENT_NS_SYM = sym.symbol('current-ns', ns='basilisp.test')
21+
22+
23+
def pytest_collect_file(parent, path):
24+
"""Primary PyTest hook to identify Basilisp test files."""
25+
if path.ext == ".lpy":
26+
if path.basename.startswith("test_") or path.purebasename.endswith("_test"):
27+
return BasilispFile(path, parent)
28+
return None
29+
30+
31+
def _collected_tests() -> Optional[vec.Vector]:
32+
"""Fetch the collected tests for the namespace ns from
33+
basilisp.test/collected-tests atom. If no tests are found, return
34+
None."""
35+
var = Maybe(runtime.Var.find(_COLLECTED_TESTS_SYM)).or_else_raise(
36+
lambda: runtime.RuntimeException(f"Unable to find test Var {_COLLECTED_TESTS_SYM}."))
37+
return var.value.deref()
38+
39+
40+
def _current_ns() -> str:
41+
"""Fetch the current namespace from basilisp.test/current-ns."""
42+
var = Maybe(runtime.Var.find(_CURRENT_NS_SYM)).or_else_raise(
43+
lambda: runtime.RuntimeException(f"Unable to find test Var {_CURRENT_NS_SYM}."))
44+
ns = var.value.deref()
45+
return ns.name
46+
47+
48+
def _reset_collected_tests() -> None:
49+
"""Reset the collected tests."""
50+
var = Maybe(runtime.Var.find(_COLLECTED_TESTS_SYM)).or_else_raise(
51+
lambda: runtime.RuntimeException(f"Unable to find test Var {_COLLECTED_TESTS_SYM}."))
52+
return var.value.reset(vec.Vector.empty())
53+
54+
55+
TestFunction = Callable[[], Optional[vec.Vector]]
56+
57+
58+
class BasilispFile(pytest.File):
59+
"""Files represent a test module in Python or a test namespace in Basilisp."""
60+
def collect(self):
61+
"""Collect all of the tests in the namespace (module) given.
62+
63+
Basilisp's test runner imports the namespace which will (as a side
64+
effect) collect all of the test functions in a namespace (represented
65+
by `deftest` forms in Basilisp) into an atom in `basilisp.test`.
66+
BasilispFile.collect fetches those test functions and generates
67+
BasilispTestItems for PyTest to run the tests."""
68+
_reset_collected_tests()
69+
filename = self.fspath.basename
70+
self.fspath.pyimport()
71+
ns = _current_ns()
72+
tests = _collected_tests()
73+
for test in tests:
74+
f: TestFunction = test.value
75+
yield BasilispTestItem(test.name.name, self, f, ns, filename)
76+
77+
78+
_ACTUAL_KW = kw.keyword('actual')
79+
_EXPECTED_KW = kw.keyword('expected')
80+
_FAILURES_KW = kw.keyword('failures')
81+
_LINE_KW = kw.keyword('line')
82+
_EXPR_KW = kw.keyword('expr')
83+
_TEST_SECTION_KW = kw.keyword('test-section')
84+
85+
86+
class BasilispTestItem(pytest.Item):
87+
"""Test items correspond to a single `deftest` form in a Basilisp test.
88+
89+
`deftest` forms run each `is` assertion and collect all failures in an
90+
atom, reporting their results as a vector of failures when each test
91+
concludes.
92+
93+
The BasilispTestItem collects all the failures and returns a report
94+
to PyTest to show to the end-user."""
95+
96+
def __init__(self, name: str, # pylint: disable=too-many-arguments
97+
parent: BasilispFile,
98+
run_test: TestFunction,
99+
namespace: str,
100+
filename: str) -> None:
101+
super(BasilispTestItem, self).__init__(name, parent)
102+
self._run_test = run_test
103+
self._namespace = namespace
104+
self._filename = filename
105+
106+
def runtest(self):
107+
"""Run the tests associated with this test item.
108+
109+
If any tests fail, raise an ExceptionInfo exception with the
110+
test failures. PyTest will invoke self.repr_failure to display
111+
the failures to the user."""
112+
results: lmap.Map = self._run_test()
113+
failures: Optional[vec.Vector] = results.entry(_FAILURES_KW)
114+
if runtime.to_seq(failures):
115+
raise lexc.ExceptionInfo("Test failures", lmap.map(results))
116+
117+
def repr_failure(self, excinfo):
118+
"""Representation function called when self.runtest() raises an
119+
exception."""
120+
if isinstance(excinfo.value, lexc.ExceptionInfo):
121+
exc = excinfo.value
122+
failures = exc.data.entry(_FAILURES_KW)
123+
messages = []
124+
125+
for failure in failures:
126+
msg: str = failure.entry(0)
127+
details: lmap.Map = failure.entry(1)
128+
129+
actual = details.entry(_ACTUAL_KW)
130+
expected = details.entry(_EXPECTED_KW)
131+
132+
test_section = details.entry(_TEST_SECTION_KW)
133+
line = details.entry(_LINE_KW)
134+
section_msg = Maybe(test_section).map(lambda s: f" {s} :: ").or_else_get("")
135+
136+
messages.append("\n".join([
137+
f"FAIL in ({self.name}) ({self._filename}:{line})",
138+
f" {section_msg}{msg}",
139+
"",
140+
f" expected: {lrepr(expected)}",
141+
f" actual: {lrepr(actual)}"
142+
]))
143+
144+
return "\n\n".join(messages)
145+
return None
146+
147+
def reportinfo(self):
148+
return self.fspath, 0, self.name

tests/__init__.py

Whitespace-only changes.

tests/basilisp/__init__.py

Whitespace-only changes.

tests/basilisp/conftest.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pytest_plugins = ["pytester"]

tests/basilisp/test_string.lpy

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
(ns basilisp.string-test
2+
(:require
3+
[basilisp.string :as str]
4+
[basilisp.test :refer [deftest is]]))
5+
6+
(deftest alpha?-test
7+
(is (not (str/alpha? "")))
8+
(is (not (str/alpha? "?")))
9+
(is (not (str/alpha? "1")))
10+
(is (str/alpha? "abcdef")))
11+
12+
(deftest alphanumeric?-test
13+
(is (not (str/alphanumeric? "")))
14+
(is (not (str/alphanumeric? "?")))
15+
(is (str/alphanumeric? "1"))
16+
(is (str/alphanumeric? "abcdef")))
17+
18+
(deftest digits?-test
19+
(is (not (str/digits? "")))
20+
(is (not (str/digits? "?")))
21+
(is (not (str/digits? "abcdef")))
22+
(is (str/digits? "1"))
23+
(is (str/digits? "1375234723984")))

0 commit comments

Comments
 (0)