feat: add new assertions test type

This commit is contained in:
technofab 2026-10-15 19:27:05 +02:00
parent 6c4c1bace6
commit 17d08b9975
Signed by: technofab
SSH key fingerprint: SHA256:bV4h88OqS/AxjbPn66uUdvK9JsgIW4tv3vwJQ8tpMqQ
10 changed files with 603 additions and 16 deletions

296
lib/asserts.nix Normal file
View file

@ -0,0 +1,296 @@
{lib, ...}: let
inherit (lib) generators;
# Format any value to a readable string safely
formatVal = val:
if builtins.isString val
then val
else if builtins.isBool val || builtins.isInt val || builtins.isFloat val
then builtins.toString val
else generators.toPretty {} val;
mkFailure = {
message ? "Assertion failed",
expected ? null,
actual ? null,
}: {
ok = false;
inherit message;
expected =
if expected != null
then formatVal expected
else "";
actual =
if actual != null
then formatVal actual
else "";
};
in rec {
inherit formatVal;
equal = expected: actual:
if actual == expected
then true
else
mkFailure {
message = "Expected values to be equal";
inherit expected actual;
};
eq = equal;
notEqual = expected: actual:
if actual != expected
then true
else
mkFailure {
message = "Expected values to not be equal";
expected = "not ${formatVal expected}";
inherit actual;
};
ne = notEqual;
greaterThan = actual: min:
if actual > min
then true
else
mkFailure {
message = "Expected value to be greater than ${formatVal min}";
expected = "> ${formatVal min}";
inherit actual;
};
gt = greaterThan;
greaterThanOrEqual = actual: min:
if actual >= min
then true
else
mkFailure {
message = "Expected value to be greater than or equal to ${formatVal min}";
expected = ">= ${formatVal min}";
inherit actual;
};
gte = greaterThanOrEqual;
lessThan = actual: max:
if actual < max
then true
else
mkFailure {
message = "Expected value to be less than ${formatVal max}";
expected = "< ${formatVal max}";
inherit actual;
};
lt = lessThan;
lessThanOrEqual = actual: max:
if actual <= max
then true
else
mkFailure {
message = "Expected value to be less than or equal to ${formatVal max}";
expected = "<= ${formatVal max}";
inherit actual;
};
lte = lessThanOrEqual;
contains = container: item:
if builtins.isList container
then
if builtins.elem item container
then true
else
mkFailure {
message = "Expected list to contain item";
expected = "to contain: ${formatVal item}";
actual = container;
}
else if builtins.isString container
then
if lib.strings.hasInfix item container
then true
else
mkFailure {
message = "Expected string to contain substring";
expected = "to contain: ${formatVal item}";
actual = container;
}
else
mkFailure {
message = "Expected list or string for 'contains'";
actual = container;
};
notContains = container: item:
if builtins.isList container
then
if !(builtins.elem item container)
then true
else
mkFailure {
message = "Expected list to not contain item";
expected = "not to contain: ${formatVal item}";
actual = container;
}
else if builtins.isString container
then
if !(lib.strings.hasInfix item container)
then true
else
mkFailure {
message = "Expected string to not contain substring";
expected = "not to contain: ${formatVal item}";
actual = container;
}
else
mkFailure {
message = "Expected list or string for 'notContains'";
actual = container;
};
hasAttr = attr: set:
if !(builtins.isAttrs set)
then
mkFailure {
message = "Expected attrset for 'hasAttr'";
actual = set;
}
else if builtins.hasAttr attr set
then true
else
mkFailure {
message = "Expected attrset to have attribute '${attr}'";
expected = "to have attribute: ${attr}";
actual = "available attributes: ${formatVal (builtins.attrNames set)}";
};
missingAttr = attr: set:
if !(builtins.isAttrs set)
then
mkFailure {
message = "Expected attrset for 'missingAttr'";
actual = set;
}
else if !(builtins.hasAttr attr set)
then true
else
mkFailure {
message = "Expected attrset to not have attribute '${attr}'";
expected = "not to have attribute: ${attr}";
actual = "found attribute '${attr}' with value: ${formatVal set.${attr}}";
};
hasAttrs = attrs: set:
if !(builtins.isAttrs set)
then
mkFailure {
message = "Expected attrset for 'hasAttrs'";
actual = set;
}
else let
missing = builtins.filter (a: !builtins.hasAttr a set) attrs;
in
if missing == []
then true
else
mkFailure {
message = "Expected attrset to have attributes ${formatVal attrs}";
expected = "missing attributes: ${formatVal missing}";
actual = "available attributes: ${formatVal (builtins.attrNames set)}";
};
matches = regex: str:
if !(builtins.isString str)
then
mkFailure {
message = "Expected string for 'matches'";
actual = str;
}
else if builtins.match regex str != null
then true
else
mkFailure {
message = "Expected string to match regex '${regex}'";
expected = "match regex: ${regex}";
actual = str;
};
isNull = val:
if val == null
then true
else
mkFailure {
message = "Expected value to be null";
expected = "null";
actual = val;
};
isNotNull = val:
if val != null
then true
else
mkFailure {
message = "Expected value to not be null";
expected = "not null";
actual = "null";
};
isEmpty = val: let
empty =
if builtins.isList val
then val == []
else if builtins.isAttrs val
then val == {}
else if builtins.isString val
then val == ""
else false;
in
if empty
then true
else
mkFailure {
message = "Expected value to be empty";
expected = "empty";
actual = val;
};
isNotEmpty = val: let
notEmpty =
if builtins.isList val
then val != []
else if builtins.isAttrs val
then val != {}
else if builtins.isString val
then val != ""
else true;
in
if notEmpty
then true
else
mkFailure {
message = "Expected value to not be empty";
expected = "not empty";
actual = val;
};
throws = expr: let
res = builtins.tryEval expr;
in
if !res.success
then true
else
mkFailure {
message = "Expected expression to throw an error, but it succeeded";
expected = "to throw";
actual = "evaluated to: ${formatVal res.value}";
};
fails = throws;
custom = cond: message:
if cond
then true
else
mkFailure {
inherit message;
};
that = custom;
}

View file

@ -6,6 +6,7 @@
inherit (lib) evalModules toList;
in rec {
helpers = import ./testHelpers.nix {inherit lib;};
inherit (helpers) asserts expect;
mkBinary = {
nixtests,

View file

@ -73,9 +73,9 @@
in "${fileRelative}:${toString val.line}";
};
type = mkOption {
type = types.enum ["unit" "snapshot" "script" "vm"];
type = types.enum ["unit" "snapshot" "script" "assertions" "vm"];
description = ''
Type of test, has to be one of "unit", "snapshot", "script", or "vm".
Type of test, has to be one of "unit", "snapshot", "script", "assertions", or "vm".
'';
default = "unit";
apply = value:
@ -84,7 +84,9 @@
assert assertMsg (value == "unit" -> !isUnset config.expected)
"test '${config.name}' as type 'unit' requires 'expected' to be set";
assert assertMsg (value == "unit" -> (xor (isUnset config.actual) (isUnset config.actualDrv)))
"test '${config.name}' as type 'unit' requires only 'actual' OR 'actualDrv' to be set"; value;
"test '${config.name}' as type 'unit' requires only 'actual' OR 'actualDrv' to be set";
assert assertMsg (value == "assertions" -> !isUnset config.assertions)
"test '${config.name}' as type 'assertions' requires 'assertions' to be set"; value;
};
name = mkOption {
type = types.str;
@ -172,6 +174,16 @@
'';
};
};
assertions = mkUnsetOption {
type = types.attrsOf (types.oneOf [types.bool types.str types.attrs]);
description = ''
Attribute set of assertions to run for this test.
Each assertion can evaluate to:
- boolean (`true` for pass, `false` for fail)
- string (custom failure message)
- matcher result attrset (e.g. from `ntlib.asserts.*`)
'';
};
finalConfig = mkOption {
internal = true;
@ -180,11 +192,75 @@
};
config = {
finalConfig = builtins.addErrorContext "[nixtest] while processing test ${config.name}" {
inherit (config) name expected actual actualDrv;
inherit (config) name expected actual actualDrv pos description;
type =
if config.type == "vm"
then "script"
else config.type;
assertions =
if config.type == "assertions"
then
assert assertMsg (!isUnset config.assertions)
"test '${config.name}' as type 'assertions' requires 'assertions' to be set";
assert assertMsg (builtins.isAttrs config.assertions)
"test '${config.name}' assertions must be an attribute set";
builtins.mapAttrs (
assertName: assertionExpr: let
evalRes = builtins.tryEval assertionExpr;
in
if !evalRes.success
then {
ok = false;
error = true;
message = "Nix evaluation error (expression threw or aborted)";
}
else let
val = evalRes.value;
in
if builtins.isBool val
then {
ok = val;
message =
if val
then ""
else "Assertion evaluated to false";
}
else if builtins.isString val
then {
ok = false;
message = val;
}
else if builtins.isAttrs val && (val ? ok)
then {
ok = val.ok;
error = val.error or false;
message = val.message or "";
expected =
if val ? expected
then
(
if builtins.isString val.expected
then val.expected
else generators.toPretty {} val.expected
)
else "";
actual =
if val ? actual
then
(
if builtins.isString val.actual
then val.actual
else generators.toPretty {} val.actual
)
else "";
}
else {
ok = false;
message = "Invalid assertion return type: expected boolean, failure message string, or matcher result";
}
)
config.assertions
else unset;
script =
if config.type == "vm"
then

View file

@ -1,7 +1,10 @@
{lib, ...}: {
{lib, ...}: rec {
path = pkgs: "export PATH=${lib.makeBinPath pkgs}";
pathAdd = pkgs: "export PATH=$PATH:${lib.makeBinPath pkgs}";
scriptHelpers = builtins.readFile ./scriptHelpers.sh;
toJsonFile = any: builtins.toFile "actual" (builtins.unsafeDiscardStringContext (builtins.toJSON any));
toPrettyFile = any: builtins.toFile "actual" (lib.generators.toPretty {} any);
asserts = import ./asserts.nix {inherit lib;};
expect = asserts;
}