Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: implement unit testing framework #111

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions run_tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#! /bin/bash

set -o pipefail
set -o nounset
set -o errexit

red="$(tput setaf 1)"
green="$(tput setaf 2)"
reset="$(tput sgr0)"

failed=0
for jsonnet_test in $(ls tests/*.jsonnet); do
echo "${green}Running ${jsonnet_test}...${reset}"
./jsonnet \
--code-file "tests=${jsonnet_test}" \
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will cause the stacktrace to refer to extvar:tests rather than the name of the file. But perhaps this is something that should be fixed elsewhere.

--exec \
--string \
'local testing = import "stdlib/testing.jsonnet"; testing.run(std.extVar("tests"))' \
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could also --exec 'import "'"${jsonnet_test}"'"'
That would fix the filename thing.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's also not unthinkable to have some jsonnet test foo.jsonnet command.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that we are doing a fair amount of special-casing for the purposes of running tests, such as adding the --code-file flag, etc. Is there a reason why we cannot evaluate Jsonnet files with tests the same we evaluate any other Jsonnet file?

&& errno=0 || errno=${?}

if [[ "${errno}" != "0" ]]; then
((failed++)) || :
echo "${red}${jsonnet_test} failed${reset}"
echo
fi
done

if [[ "${failed}" -gt 0 ]]; then
echo "${red}(${failed}/$(ls tests/*.jsonnet | wc -l)) test packages failed.${reset}"
exit 1
fi

echo "${green}OK${reset}"
exit 0

139 changes: 139 additions & 0 deletions stdlib/testing.jsonnet
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/*
Test:
{
name: string,required
cases: object[],optional
// if cases is not null
evalute(case):: (object -> CaseResult)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo: evaluate
same 2 lines down

// if cases is null
evalute():: (() -> CaseResult)
}

Case:
{
name: string,optional

...testdata...
}

CaseResult:
{
name:
passed: bool,required
message: string,optional
}

TestResult:
{
name: string,required
passed: bool,required
results: CaseResult[]
}

*/
{

local testing = $,

format(results)::
local testResultTemplate =
"--- %(result)s '%(name)s'\n";
local caseResultTemplate =
"\t%(result)s\t'%(name)s'";
local caseResultMessageTemplate =
":\n %(message)s\n";

local formatResults(str, cur, results, formatResult) =
if std.length(results) == cur then
str
else
formatResults(
str + formatResult(cur, results[cur]),
cur + 1,
results,
formatResult
) tailstrict;

local formatCaseResult(cur, caseResult) =
local result =
if caseResult.passed then
"pass"
else
"fail";
local name =
if std.objectHas(caseResult, "name") then
caseResult.name
else
"case %s" % cur;
local caseResultString = caseResultTemplate % {result: result, name: name};

local message =
if std.objectHas(caseResult, "message") then
caseResultMessageTemplate % caseResult
else
"\n";

caseResultString + message;

local formatTestCases(str, results) =
formatResults(str, 0, results, formatCaseResult);

local formatTestResult(cur, testResult) =
local str = testResultTemplate % {
result:
if testResult.passed then
"PASS"
else
"FAIL",
name: testResult.name,
};
formatTestCases(str, testResult.results);

local formatTestResults(str, cur, results) =
formatResults(str, cur, results, formatTestResult);

formatTestResults("", 0, results),

passed(results)::
std.foldl(function(cur,result) cur && result.passed, results, true),

evaluateToCaseResult(obj)::
if obj == null then
{ passed: true }
else if std.type(obj) == "string" then
{ passed: false, message: obj }
else
error("evaluate must return a failure message or {}."),

evaluateTest(name, test)::
local caseResults =
if std.type(test) == "object" then
[
testing.evaluateToCaseResult(test.evaluate(test.cases[i])) + test.cases[i]
for i in std.range(0, std.length(test.cases) - 1)
]
else if std.type(test) == "function" then
[ testing.evaluateToCaseResult(test()) ]
else
error "tests muts be objects or functions";
{
name: name,
passed: testing.passed(caseResults),
results: caseResults
},

evaluate(tests)::
if std.type(tests) == "object" then
local results =
[ testing.evaluateTest(name, tests[name]) for name in std.objectFields(tests) ];
results
else
error("input must be a test object"),

run(input)::
local results = testing.evaluate(input);
if testing.passed(results) then
testing.format(results)
else
error("\n" + testing.format(results)),
}
77 changes: 77 additions & 0 deletions stdlib/truth.jsonnet
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
{


expect()::
self,

that(subject)::
if std.objectHas(self, "subject") then
error("subject cannot be set twice")
else
self + { subject: subject },


// returns the first string representing a failed expecatation or returns
// null if no assertions failed.
expectations(arr)::
std.foldl(
function(cur, result)
if cur != null then
cur
else
if result != null then
result,
arr,
null,
),

fatalFailures: false,

fatal()::
self + { fatalFailures: true},

fail(message)::
if self.fatalFailures then
error(message)
else
message,

local fail = self.fail,

named(name)::
if std.objectHas(self, "name") then
error("name cannot be set twice")
else
self + { name: name },


// propositions

hasType(object)::
if std.type(self.subject) != object then
fail("expected '%s' to be of type '%s' but was of type '%s'" % [ self.subject, object, std.type(self.subject) ]),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: @sparkprime - Do we have a recommended line length limit? AFAIU, the line length we're using for the C++ code is 100 characters.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rest of the Jsonnet code in the repo uses 4 and 100. It may be time to write a style guide.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I've opened #118 to discuss a style guide.


isEqualTo(object)::
if self.subject != object then
fail("expected '%s' and '%s' to be equal" % [ self.subject, object ]),

isNotEqualTo(object)::
if self.subject == object then
fail("expected '%s' and '%s' not to be equal" % [ self.subject, object ]),

isTrue()::
self.expectations([
self.hasType("boolean"),
if !self.subject then
fail("expected true"),
]),

isFalse()::
self.expectaions([
self.hasType("boolean"),
if self.subject then
fail("expected false"),
]),


}
52 changes: 0 additions & 52 deletions test_suite/arith_bool.jsonnet

This file was deleted.

Loading