exist icon indicating copy to clipboard operation
exist copied to clipboard

Allow custom assertions in XQSuite

Open line-o opened this issue 3 years ago • 66 comments

Description:

Add new functions test:fail#3 and test:fail#4 to XQSuite. Calling one of these inside a XQSuite test function will stop execution by throwing a special error test:failure. This error is caught and handled as a failure allowing to create arbitrary custom assertions and the ability to provide specific error messages.

(edit) After @adamretter's feedback the order of arguments was changed.

  • test:fail#3: fail with custom message, expected and actual value
  • test:fail#4: fail with custom message, expected and actual value and a custom test type

Works in both XQuery and jUnit context. In jUnit you will only be presented with the serialized expected and actual values.

(edit) In order to provide this facility also to packages that target older versions of eXist-db, which might not have the test:fail function available both

  • the QName of the error that is treated as a failure $test:FAILURE
  • the default custom assertion type $test:CUSTOM_ASSERTION_FAILURE_TYPE

are exposed. This allows to fall back to throwing the error directly in custom assertion code. In case of a failed assertion the test case will still fail. Though not as a failure but an error. Also expected and actual value might not be displayed nicely. But the provided custom message will still add value.

Example

test.xqm

xquery version "3.1";

module namespace t="http://exist-db.org/xquery/test/xqsuite";

import module namespace assert="http://line-o.de/xq/assert" at "./assert.xqm";

declare namespace test="http://exist-db.org/xquery/xqsuite";

declare variable $t:var := map {"a": 1, "b": 2};

declare %test:assertTrue function t:test-missing-key() as xs:boolean? {
    assert:map($t:var, map {"a": 1, "c": 4})
};

declare %test:assertTrue function t:test-wrong-value() as xs:boolean? {
    assert:map($t:var, map {"a": 1, "b": 3})
};

declare %test:assertTrue function t:test-wrong-type() as xs:boolean? {
    assert:map($t:var, [1,2])
};

declare %test:assertTrue function t:test-passing() as xs:boolean? {
    assert:map($t:var, $t:var)
};

assert.xqm

xquery version "3.1";

module namespace assert="http://line-o.de/xq/assert";

import module namespace test="http://exist-db.org/xquery/xqsuite"
    at "resource:org/exist/xquery/lib/xqsuite/xqsuite.xql";

declare function assert:map ($e as map(*), $a as item()*) as xs:boolean? {
    if (exists($a) and count($a) eq 1  and $a instance of map(*))
    then (
        for-each(map:keys($e), function ($key as xs:anyAtomicType) {
            if (not(map:contains($a, $key)))
            then test:fail("Key " || $key || " is missing", $e, $a, "map-assertion")
            else if ($e($key) ne $a($key))
            then test:fail("Value mismatch for key '" || $key || "'", $e, $a, "map-assertion")
            else ()
        })
        ,
        true()
    )
    else test:fail("Type mismatch", $e, $a, "map-assertion")
};

Running the above will yield

<testsuites>
    <testsuite package="http://exist-db.org/xquery/test/xqsuite" timestamp="2022-04-12T14:24:15.72+02:00" 
        tests="4" failures="3" errors="0" pending="0" time="PT0.008S">
        <testcase name="test-missing-key" class="t:test-missing-key">
            <failure message="Key 'b' is missing" type="map-assertion"/>
            <output>map{"a":1,"c":4}</output>
        </testcase>
        <testcase name="test-passing" class="t:test-passing"/>
        <testcase name="test-wrong-type" class="t:test-wrong-type">
            <failure message="Type mismatch" type="map-assertion"/>
            <output>[1,2]</output>
        </testcase>
        <testcase name="test-wrong-value" class="t:test-wrong-value">
            <failure message="Value mismatch for key 'b'" type="map-assertion"/>
            <output>map{"a":1,"b":3}</output>
        </testcase>
    </testsuite>
</testsuites>

Reference:

This was added due to the limitations of XQuery function annotations and will allow to compare complex values, loading fixtures and assertions from modules and provide human readable feedback via custom error messages.

Type of tests:

XQSuite tests in custom-assertion.xqm

line-o avatar Apr 12 '22 12:04 line-o

I have a concern that your test:fail function is mixing concerns, it appears to do two things:

  1. check that expected and actual match
  2. log a failure message (conditionally if (1) is false)

The problem with mixing concerns is that we are then not creating composable building blocks.

I think it needs to be simplified to do one thing, i.e. fail with an optional code and message. For a good example, see the JUnit fail function, it does a single thing. fail is certainly not meant to be an assertion!

adamretter avatar Apr 12 '22 13:04 adamretter

test:fail does not assert anything @adamretter As you can clearly see in the provided example.

line-o avatar Apr 12 '22 16:04 line-o

test:fail does not assert anything @adamretter As you can clearly see in the provided example.

The example isn't clear at all as it uses an assert library which isn't part of this PR. Instead I was reading the commits themselves...

adamretter avatar Apr 12 '22 17:04 adamretter

Ah I see... It doesn't assert, however including them as args to test:fail, still mixes concerns and by extension limits the usefulness of test:fail.

When I have a moment later I will try and propose some alternative function signatures, which still allow you to achieve the same goal but are more composable

adamretter avatar Apr 12 '22 17:04 adamretter

In order to get the desired test output on failure it is crucial to pass both expected and actual to test:fail. Function annotations are not sufficient to handle these.

line-o avatar Apr 12 '22 19:04 line-o

Thanks Juri. I understand and I am generally in favour of this, I think it just needs a little tweaking and discussion

adamretter avatar Apr 12 '22 20:04 adamretter

@adamretter I am all for discussions. I would appreciate it, though, that you state what you think needs to change and why. From reading through your comments, I understand that passing expected and actual to test:fail is what you dislike. I already pointed out why this needs to happen. So the question remains how do you envision to pass those two values to the error that is thrown or more importantly the error handling code?

line-o avatar Apr 13 '22 08:04 line-o

I think the full function signature for a test:fail function should look like:

test:fail($code as xs:QName?, $description as xs:string, $failure-object as item()*) as none

We can then also have:

  1. test:fail()
  2. test:fail($code as xs:QName?)
  3. test:fail($code as xs:QName?, $description as xs:string)

I have chosen the above function signatures to mirror that of fn:error, as failure is a parallel concept to error, and @line-o's proposed implementation uses fn:error itself.

By not mixing concerns, i.e. not forcing the user to include the expected and actual values of an assertion, the test:fail function now does one thing and one thing only. There are of course also many use case, where you may want to fail when there is no assertion to be made; JUnit's fail function demonstrates that! The user is still free to provide any additional values they need (e.g. expected and actual values) in the $failure-object sequence.

A new implementation of such a function might look like:

declare function test:fail($code as xs:QName?, $description as xs:string, $failure-object as item()*) as empty-sequence() {
    fn:error(xs:QName("test:fail"), $description, ($code, $failure-object))
};

adamretter avatar Apr 14 '22 09:04 adamretter

@adamretter I incorporated your feedback.

line-o avatar Apr 14 '22 13:04 line-o

Kudos, SonarCloud Quality Gate passed!    Quality Gate passed

Bug A 0 Bugs
Vulnerability A 0 Vulnerabilities
Security Hotspot A 0 Security Hotspots
Code Smell A 0 Code Smells

No Coverage information No Coverage information
No Duplication information No Duplication information

sonarqubecloud[bot] avatar Apr 14 '22 14:04 sonarqubecloud[bot]

@line-o You still appear to have the old versions of test:fail which mix concerns (i.e. they still expect actual and expected values). Can you remove those please as they are now unnecessary.

adamretter avatar Apr 14 '22 21:04 adamretter

@adamretter I cannot follow. Both expected and actual are necessary in every case.

line-o avatar Apr 14 '22 23:04 line-o

@line-o The premise behind all of my concerns with this PR is that having actual and expected values as explicit arguments mixes concerns in a negative way!

Instead, with my proposal you have $failure-objects, and you can simply do this instead (if you wish), which is equivalent but doesn't mix concerns:

test:fail($code, $description, ($expected, $actual))

adamretter avatar Apr 15 '22 08:04 adamretter

Your proposal, @adamretter, will not work. Here are the main issues:

  • both the actual and expected values can be sequences and will be mashed together into a single sequence making it impossible for later code to distinguish them
  • the later code needs to handle missing values (throwing an error in the error handler?)
  • assertion developers need to keep in mind what and how is to be put there and no tooling will help them remember that

line-o avatar Apr 15 '22 10:04 line-o

both the actual and expected values can be sequences and will be mashed together into a single sequence making it impossible for later code to distinguish them

That is solved simply by using a Map, e.g.:

test:fail($code, $description, map {"expected" : $expected, "actual": $actual})

the later code needs to handle missing values (throwing an error in the error handler?)

I am not sure what you mean by "the later code" - can you show me where to find this?

assertion developers need to keep in mind what and how is to be put there and no tooling will help them remember that

Again I am afraid, I am not sure what you mean by this. Can you show me where to find this? Also, keep in mind that not "all assertions" involve comparing actual and expected values, or even comparing values. The Hamcrest library can show you some good examples of other assertions that don't involve values.

adamretter avatar Apr 15 '22 12:04 adamretter

@adamretter I am sure you noticed that test:fail#0, and test:fail#1 were added to this PR along with their tests. So, if an assertion does not need to expected and actual vaiue it is as easy as:

declare %test:assertTrue function t:random-is-odd() as xs:boolean? {
    if (( random-number-generator()?number * 1e16 ) mod 2)
    then test:fail()
    else true()
};

Regarding the usage of a map. Yes, this would solve the problem of keeping sequences apart. On the flip side there is no tooling in place to help you keep in mind what keys need to propagated. Whereas having them as explicit arguments means that in code editors like eXide, VSCode and intelliJ you will be presented with useful hints what needs to be added. And if one does not use those the XQuery compiler of existdb will throw a static error with the line number and column number where the argument is missing.

The "later code" is here https://github.com/eXist-db/exist/blob/8464ae4ea69455c4ff0d5c61a591373593587ef5/exist-core/src/main/resources/org/exist/xquery/lib/xqsuite/xqsuite.xql#L425-L446

line-o avatar Apr 17 '22 10:04 line-o

While one can leave the values empty, it will result in following output in jUnit Screenshot 2022-04-17 at 13 47 47

line-o avatar Apr 17 '22 12:04 line-o

#confused

dizzzz avatar Apr 18 '22 18:04 dizzzz

I believe two key purposes of a testing framework got confused here:

  • assertions
  • reporting

While the former has to have the expected and actual value the latter needs to know these only if the reporter wants to display these values. A dot-reporter for example only needs to know if the test passed or failed.

But all the reporters of XQSuite I am aware of - XML, HTML, jUnit - do display at least one of them.

That is why I chose to require them in the design of test:fail instead of making them optional. It is also impossible for the test function to know which reporter is currently used.

I have a hard time thinking of a reason why this is a bad decision and have not read a good one in the comments, yet.

line-o avatar Apr 19 '22 13:04 line-o

The only point I can think of is that it would be one parameter less, three instead of four.

Developer experience and help from tooling outweighs this argument by a ton in my opinion.

line-o avatar Apr 19 '22 13:04 line-o

I think there are some things here that still need to be discussed and developed further, I will try and find some time later in the week to take a look

adamretter avatar Apr 20 '22 17:04 adamretter

As I read the discussion, the feature in itself is not in question but rather points related to usage? I agree that this needs to be well-discussed as we should not release a feature people will rely on with the “threat” of major changes in the near future – especially not as this would require people to rewrite their tests.

Can we compile a list of concerns and possible pitfalls that should be discussed? That may be a point for an upcoming call’s agenda.

dariok avatar Aug 01 '22 18:08 dariok

@adamretter can you elaborate on why you are against merging this PR? It's been several months.

line-o avatar Sep 08 '22 11:09 line-o

@dariok merging into the develop branch is not the same as releasing a feature. The develop branch allows developers and users to test features and provide feedback together with everything else that's happening in exist. The risk of changes to features currently on the develop branch is well documented and communicated.

If further changes are necessary before a release, then that's a separate question. The question at hand is, if the PR is ready to be merged into develop. As it is not a breaking change, I d say yes. Further development of this or other features before or after the next release should happen in a new pr.

duncdrum avatar Sep 08 '22 12:09 duncdrum

+1 for me pulling in; maybe not perfect but good enough

dizzzz avatar Sep 08 '22 20:09 dizzzz

@adamretter can you elaborate on why you are against merging this PR? It's been several months.

Sure, I have a long train journey today, and so I have found the time to re-evaluate and reframe the issues I raised above. As it seems some people did not understand the concerns I previously raised, I have also added examples to try and make this clearer. Hope it helps :-)


Previously XQSuite only made assertions via annotations. A test could only either Pass or Fail based on the annotations, or raise an Error.

XQSuite was originally designed so that the test assertions could be placed upon the actual code in question, evidence and examples of that can be seen here: https://exist-db.org/exist/apps/doc/xqsuite

The XQsuite test framework allows tests to be added to functions using function annotations. Since annotations are ignored during normal execution, this does not interfere.

In practice it seems, especially within the eXist-db database code base, XQSuite is not used in this manner as originally intended. Instead tests are written as standalone Test Suite modules which stand beside the code under test, this is also supported by XQSuite as stated:

More complex integration tests can be combined into separate modules.

The nature of XQSuite is somewhat vague, at no point does it descibe itself as suitable for Unit Tests, instead it mentions that it can be used for Integration Tests. However, arguably depending on the nature of the function under test, it can just as readily be used for writing Unit Tests.

This PR introduces Unit Test like functions for making assertions, thus turning XQSuite into quite a different tool. Previously, all assertions could only be made via annotations in a purely functional manner.

It should be noted that such additional functions are incompatible with XQSuite's original goal of testing existing code inline, i.e. without writing a separate Test Suite Module.

In principle I think that making XQSuite a more flexible framework is a positive gain. Whilst such additional assertion functions can only be used from Test Suite modules, as a great deal of tests are written in this manner, these functions can offer new and additional capabilities. The addition of such functions to XQSuite should remain an optional choice for the user, allowing the annotation approach to be used by default.

I would assert that adding such functions actually changes XQSuite into a framework that now has two personas:

  1. The existing functional persona which is restricted to purely functional programming.
  2. A new procedural presona which is more familiar to programmers coming from other languages. This persona very much adopts concepts from Unit Test frameworks such as: JUnit, NUnit, and xUnit.js.

In practice it may be possible for the user to use aspects of both personas when writing tests, however it should be clearly documented that each of these peronas fit different programming/testing styles, and how they interact with each other when combined. To facilitate this I would strongly suggest that all such new assertion functions are separated into their own namespace; this can complement the existing test namespace.

Whilst I am happy in principle with adding such functions. I think the current implementation in this PR has issues, and it is these issues which I have tried to highlight previously.

Issues with this PR

In my opinion the signatures for the test:fail functions have several issues:

  1. These functions should be moved into their own namespace to indicate the difference in persona, i.e. test:fail#* -> assert:fail#*.

  2. test:fail#0 is not helpful for the user. When triggered it provides no contextual information to the user about what the failure is or why it occured. As the user has to write the code that includes this function call, they can certainly provide at least a description/label of/for the failure. I think this function enables a bad style of programming, and should be excluded to preclude users from doing things that will harm them.

  3. test:fail#1 should not introduce a new concept called $type. This $type seems to be used in the examples to indicate a string name of the user defined function that the user has called. I would argue that this is just another aspect of the description of the failure. It should not be a first class citizen, instead this can be handled in a cleaner way; see my suggestion below for an assert:assert function.

  4. test:fail#2 should remove the $type concept. This function should simply become assert:fail($message), or better yet assert:fail($error-code as xs:QName, $message as xs:string?).

  5. test:fail#3 and test:fail#4 conflates the purpose of the function with the fomatting of the falure description. These functions are unnecessary and make assumptions about how the user wishes to have their failured messages formated. They take a step too far, and are not required over test:fail#2. It is better to provide building blocks that can be composed.

As I understand it the purpose of this PR is two fold:

  1. To introduce Unit Test like functions so that users may write a more procedural style of test.
  2. To allow users to write custom assertions.

I like the idea, but I would propose an alternative set of function signatures that preserve a separation of concerns between such custom assertions, and the handling and formatting of failures and their descriptions.


By a process of deduction, we can find that we only need implement a single function to support any Unit Test style procedural assertion. This function can then be utilised to both:

  1. Allow users to write their own custom assertions.
  2. Provide a utility library of pre-defined common assertions that may be useful to the user.

The signature for such a function could be:

assert:assert(function() as xs:anyAtomicType+) as empty-sequence()

Proposal

I propose the following module of assertions:

xquery version "3.1";

(:~
 : A module which provides xUnit like assertions.
 : 
 : These assertions may be used with XQSuite.
 : 
 : These assertions may also be used in other contexts by simply
 : catching the error $assert:assert-failure, i.e.: "assert:assert-failure".
 :
 : @author <a href="mailto:[email protected]">Adam Retter</a>
 :)
module namespace assert = "http://exist-db.org/xquery/xqassert";

declare namespace test = "http://exist-db.org/xquery/test";

declare variable $assert:assert-failure := xs:QName("assert:assert-failure");

(:~
 : This function is the root function for all assert functions.
 :
 : @param predicate a predicate which determines if the assertion passes or fails.
 :     Return type of $predicate is (xs:boolean, xs:QName?, xs:string?) i.e. ($result, $error-code, $message).
 :     Iff xs:boolean is fn:false() then xs:QName should be present, and xs:string is optional,
 :     Iff xs:boolean is fn:true() then xs:QName, and xs:string can be omitted.
 :
 :)
declare function assert:assert($predicate as function() as xs:anyAtomicType+) as empty-sequence() {
	let $result := $predicate()
	return
       if ($result[1] eq fn:false())
       then
		if (fn:count($result) lt 2)
		then
			fn:error(xs:QName("assert:invalid-predicate-fn"), "The predicate passed to assert:assert#1 only returned 1 value, but at least 2 values are required")
		else
       		let $description as xs:string := ($result[3], $result[2])[1]
       		return
       		     (: NOTE: When the assert functions are used from within XQSuite, XQSuite will catch this $assert:assert-failure (i.e.: "assert:assert-failure") error and process it as a failure instead of an error :)
       		     fn:error($assert:assert-failure, $description, ($result[2], fn:function-name($predicate)))
	  else ()
};


(: A library of assert Utility functions follows below for the convenience of users :)

(: TODO(AR) add XQDoc to below functions if we decide to progress this module :)

declare function assert:fail($error-code as xs:QName, $message as xs:string?) as empty-sequence() {
    assert:assert(function() {
    	fn:false(),
    	$error-code,
    	$message
    })
};

declare function assert:fail($error-code as xs:QName) as empty-sequence() {
    assert:fail($error-code, ())
};

declare %private function assert:equal($error-code as xs:QName, $message as xs:string?, $message-formatter as function(xs:string?, item()+, item()+) as xs:string, $expected as item()*, $actual as item()*, $predicate as function() as xs:boolean) as empty-sequence() {
    assert:assert(function() {
    	let $result := $predicate()
    	return
    	(
    		$result,
    		if ($result)
    		then
    			()
    		else
    			(
    				$error-code,
    				$message-formatter($message, $expected, $actual)
    			)
    	)
    })
};

declare function assert:equal($error-code as xs:QName, $message as xs:string?, $message-formatter as function(xs:string?, item()+, item()+) as xs:string, $expected as item()*, $actual as item()*) as empty-sequence() {
	assert:equal($error-code, $message, $message-formatter, $expected, $actual, function() { $expected = $actual })
};

declare variable $assert:default-equality-message-formatter := function($message as xs:string?, $expected as item()*, $actual as item()*) as xs:string {
	fn:string-join(
		(
			$message,
			"Expected: " || fn:serialize($expected, map { "method": "adaptive" }) || ".",
			"Actual: " || fn:serialize($actual, map { "method": "adaptive" }) || "."
		),
		" "
	)
};

declare function assert:equal($error-code as xs:QName, $message as xs:string?, $expected as item()*, $actual as item()*) as empty-sequence() {
    assert:equal($error-code, $message, $assert:default-equality-message-formatter, $expected, $actual)
};

declare function assert:equal($error-code as xs:QName, $expected as item()*, $actual as item()*) as empty-sequence() {
    assert:equal($error-code, (), $expected, $actual)
};

declare function assert:deep-equal($error-code as xs:QName, $message as xs:string?, $message-formatter as function(xs:string?, item()+, item()+) as xs:string, $expected as item()*, $actual as item()*) as empty-sequence() {
	assert:equal($error-code, $message, $message-formatter, $expected, $actual, function() { fn:deep-equal($expected, $actual) })
};

declare function assert:deep-equal($error-code as xs:QName, $message as xs:string?, $expected as item()*, $actual as item()*) as empty-sequence() {
    assert:deep-equal($error-code, $message, $assert:default-equality-message-formatter, $expected, $actual)
};

declare function assert:deep-equal($error-code as xs:QName, $expected as item()*, $actual as item()*) as empty-sequence() {
    assert:deep-equal($error-code, (), $expected, $actual)
};

Examples of using such assertions are:

import module namespace assert = "http://exist-db.org/xquery/xqassert" at "xmldb:exist:///db/xqassert.xqm";



(:~ This is an example of a custom assertion that fails :)
(:
assert:assert(
	function() { fn:false(), xs:QName("local:CUSTOM-LITTLE-FAIL-1"), "This is an example of a custom assertion that fails" }
)
:)


(:~ This is an example of a custom assertion that passes :)
(:
assert:assert(
	function() { fn:true() }
)
:)

(:~ This is an example of calling the utility function assert:fail#1 :)
(:
assert:fail(xs:QName("local:CUSTOM-LITTLE-FAIL-2"))
:)

(:~ This is an example of calling the utility function assert:fail#2 :)
(:
assert:fail(xs:QName("local:CUSTOM-LITTLE-FAIL-3"), "This is an example of calling the utility function assert:fail#2")
:)

(:~ This is an example of calling the utility function assert:equal#3 :)
(:
assert:equal(xs:QName("local:CUSTOM-LITTLE-FAIL-4"), "a", "b")
:)

(:~ This is an example of calling the utility function assert:equal#4 :)
(:
assert:equal(xs:QName("local:CUSTOM-LITTLE-FAIL-5"), "friend, your things are not equal", "a", "b")
:)

(:~ This is an example of calling the utility function assert:equal#5 which allows you to format your own failure message :)
(:
let $my-formatter := function($message, $expected, $actual) { "I say whatever I want!" }
return
	assert:equal(xs:QName("local:CUSTOM-LITTLE-FAIL-6"), "friend, your things are not equal", $my-formatter, "a", "b")
:)

(:~ This is an example of calling the utility function assert:deep-equal#3 :)
(:
assert:deep-equal(xs:QName("local:CUSTOM-LITTLE-FAIL-7"), "a", "b")
:)

(:~ This is an example of calling the utility function assert:deep-equal#4 :)
(:
assert:deep-equal(xs:QName("local:CUSTOM-LITTLE-FAIL-8"), "friend, your things are not deep-equal", "a", "b")
:)

(:~ This is an example of calling the utility function assert:deep-equal#5 which allows you to format your own failure message :)
let $my-formatter := function($message, $expected, $actual) { "I deeply think, before I say what I mean!" }
return
	assert:deep-equal(xs:QName("local:CUSTOM-LITTLE-FAIL-9"), "friend, your things are not deep-equal", $my-formatter, "a", "b")

Furthermore, we can refactor @line-o approach so that it fits this more composable approach:

declare function local:assert-map($expected as map(*), $actual as item()*) as empty-sequence() {
	if (fn:count($actual) eq 1 and $actual instance of map(*))
	then
		for $expected-key in map:keys($expected)
	   	return
			if (fn:not(map:contains($actual, $expected-key)))
			then
				assert:fail(xs:QName("local:map-assertion"),  $assert:default-equality-message-formatter("Key " || $expected-key || " is missing", $expected, $actual))
			else if ($expected($expected-key) ne $actual($expected-key))
			then
				assert:fail(xs:QName("map-assertion"), $assert:default-equality-message-formatter("Value mismatch for key '" || $expected-key || "'", $expected, $actual))
			else ()
	else
		assert:fail(xs:QName("local:map-assertion"), $assert:default-equality-message-formatter("Type mismatch", $expected, $actual))
};

declare variable $local:expected := map {"a": 1, "b": 2};

(:~ This is an example of calling the custom assertion local:assert-map#2 for a map with a missing key :)

local:assert-map($local:expected, map {"a": 1, "c": 4})


(:~ This is an example of calling the custom assertion local:assert-map#2 for a map with a wrong value :)
(:
local:assert-map($local:expected, map {"a": 1, "b": 3})
:)


(:~ This is an example of calling the custom assertion local:assert-map#2 for a map with a wrong type :)
(:
local:assert-map($local:expected, [1,2])
:)


(:~ This is an example of calling the custom assertion local:assert-map#2 for a map that passes :)
(:
local:assert-map($local:expected, $local:expected)
:)

We can even go a step further and improve on the local:assert-map function to (a) introduce a bounds-check optimisation, and (b) reduce the amount of boiler-plate needed:

declare function local:improved-assert-map($expected as map(*), $actual as item()*) as empty-sequence() {
	let $actual-count := fn:count($actual)
	return
		if (fn:count($actual) eq 1 and $actual instance of map(*))
		then
			let $expected-count := fn:count($expected)
			return
				if ($expected-count eq $actual-count)
				then
					map:for-each($expected, function($expected-key, $expected-value) {
						(: First, compare the keys :)
						if (fn:not(map:contains($actual, $expected-key)))
						then
							assert:fail(xs:QName("local:map-assertion"),  $assert:default-equality-message-formatter("Key " || $expected-key || " is missing", $expected, $actual))
						else ()
						,
						(: Second, compare the values :)
						assert:equal(xs:QName("local:map-assertion"), "Value mismatch for key", $expected-value, $actual($expected-key))
					})
				else
					assert:fail(xs:QName("local:map-assertion"), $assert:default-equality-message-formatter("Maps are of different sizes", $expected-count, $actual-count))
		else
			assert:fail(xs:QName("local:map-assertion"), $assert:default-equality-message-formatter("Type mismatch", $expected, $actual))
		
};

Oustanding questions:

  • It is not clear how one can best use such assertions functions without any assert annotation at present, i.e. there is no generic %test:test assertion. Regardless, they can be used succesfully with a simple %test:assertEmpty annotation which acts as well as a generic %test:test assertion. We should perhaps add %test:test as a synonym for %test:assertEmpty.

adamretter avatar Sep 10 '22 09:09 adamretter

Interesting comments. I would like to point out that the introduction of types and the additional test:fail signatures where requested by you @adamretter . Now to my surprise you are advocating that these are the changes you dislike about this PR. What made you change your mind?

line-o avatar Sep 10 '22 09:09 line-o

see https://github.com/eXist-db/exist/pull/4332#issuecomment-1098942507

I think the full function signature for a test:fail function should look like:

test:fail($code as xs:QName?, $description as xs:string, $failure-object as item()*) as none

We can then also have:

  1. test:fail()
  2. test:fail($code as xs:QName?)
  3. test:fail($code as xs:QName?, $description as xs:string)

I have chosen the above function signatures to mirror that of fn:error, as failure is a parallel concept to error, and @line-o's proposed implementation uses fn:error itself.

By not mixing concerns, i.e. not forcing the user to include the expected and actual values of an assertion, the test:fail function now does one thing and one thing only. There are of course also many use case, where you may want to fail when there is no assertion to be made; JUnit's fail function demonstrates that! The user is still free to provide any additional values they need (e.g. expected and actual values) in the $failure-object sequence.

A new implementation of such a function might look like:

declare function test:fail($code as xs:QName?, $description as xs:string, $failure-object as item()*) as empty-sequence() {
    fn:error(xs:QName("test:fail"), $description, ($code, $failure-object))
};

line-o avatar Sep 10 '22 09:09 line-o

Also important to point out: test:fail does raise an error of a specific type. Wich in turn means it is fully compatible with the current design of XQSuite, with the exception of annotating function otherwise used in an application when this special error is not caught and handled inside the application code itself. But as @adamretter already points out this feature is de facto unused. Existing code will work as before even if it is used in that way.

line-o avatar Sep 10 '22 09:09 line-o

I a have a hard time understanding where the introduction of custom assertions changes the coding paradigm to procedural.

line-o avatar Sep 10 '22 09:09 line-o