Skip to content

Testing queries without a database

Incubating. A service that turns a request into a ModelQuery can be tested without a database, a persistence provider or a Spring context: hand the service a mocked repository or executor, capture the query it passes, and assert on what the query filters, orders and selects. model-query-test does the asserting.

Add the module

<dependency>
    <groupId>io.github.rey5137</groupId>
    <artifactId>model-query-test</artifactId>
    <scope>test</scope>
</dependency>

It depends on model-query-core and AssertJ only. The version comes from the BOM.

Capture and assert

Any mocking library works. With Mockito, capture the query a service gives a mocked ModelQueryRepository (or ModelQueryExecutor):

import static com.rey.modelquery.test.FilterMatchers.*;
import static com.rey.modelquery.test.QueryAssertions.assertThatQuery;

ArgumentCaptor<ModelQuery<OrderEntity, ?, OrderView>> query = ArgumentCaptor.forClass(ModelQuery.class);
service.search(new OrderSearch(Optional.of(OPEN), Optional.empty()));
verify(repository).findPage(query.capture(), any(), any());

assertThatQuery(query.getValue())
        .hasFilters(eq(QOrderView.STATUS, OPEN))              // exactly these, in any order; the skipped one is absent
        .isOrderedBy(QOrderView.CREATED_AT.desc());

A filter skipped by an empty Optional is simply absent, so the test says what the request turned into.

What you can assert

Assertion Checks
hasFilters(matchers...) The top-level where conditions are exactly these, in any order.
containsFilter(matcher) One top-level where condition matches.
hasNoFilters() There is no where condition.
hasHaving(...), containsHaving(...), hasNoHaving() The same, over having.
isOrderedBy(keys...), isNotOrdered() orderBy(), in order, with directions and null precedence.
hasSelection(fields...), selectionContains(fields...) The query's select().
hasFetchSelection(fields...), fetchSelectionContains(fields...), hasNoFetchPlan() The selection of the query's fetch plan.
child(field) The query the fetch plan runs for that child, asserted below.

The matchers in FilterMatchers mirror the Filters operators by name, with the value form of their parameters: eq, ne, gt, gte, lt, lte, range, between, in, notIn, like, likeIgnoreCase, eqIgnoreCase, isNull, isNotNull, compare, or, not, exists and notExists. They have no Optional forms, and no when or apply, which record nothing of their own. The aggregate overloads serve having. Two more:

  • and(...) is a branch of an or that holds two or more filters: or(and(a, b), c) is not or(a, b, c).
  • custom(label) matches a filter added with add("label", ...). Nothing else about a custom predicate can be inspected, so name the ones you want to test, with a constant, never a value.
assertThatQuery(q).containsFilter(or(eq(QOrderView.STATUS, OPEN), isNull(QOrderView.CLOSED_AT)));
assertThatQuery(q).containsFilter(exists(QOrderView.ITEMS_TABLE, gt(QOrderView.ITEM_QTY, 0)));
assertThatQuery(q).containsFilter(custom("region visible to user"));

in and notIn match their values as a multiset, so their order does not matter. Values are compared as passed to the query, before any converter. The operands of or, and, not and exists match in the order they were added.

When an assertion fails, the message prints the whole condition tree, values included, and hasFilters names the conditions that are missing and those that were not expected.

Child queries

child(field) (incubating) asserts the query the fetch plan runs to load a child: its filters, its order, its selection and its maxPerParent, with the same matchers. For the plan that loads QCustomerOrders.ORDERS with paid orders, newest total first, at most 50 per customer:

assertThatQuery(q).child(QCustomerOrders.ORDERS)
        .hasFilters(eq(QOrderLines.STATUS, "PAID"))
        .isOrderedBy(QOrderLines.TOTAL.desc())
        .selectionContains(QOrderLines.TOTAL)
        .hasMaxPerParent(50);
Assertion Checks
hasFilters(...), containsFilter(...), hasNoFilters() The child filters, without the match on the parent's key.
isOrderedBy(keys...), isNotOrdered() The child order, without the primary key that closes it.
hasSelection(fields...), selectionContains(fields...) The child plan's own selection, then the child's foreignKey unless the plan already selects it (a through child adds none), then its join plans' selections.
hasMaxPerParent(n), hasNoMaxPerParent() The bound on children per parent.

A child the plan doesn't load fails the assertion, and the message lists the children the plan does load.

Reading the conditions yourself

ModelQuery.conditions() (incubating) returns the same view the assertions read: where() and having(), each a list of Condition with kind(), column(), values(), children() and the other accessors. Its toString() and the build log show each value as ?, since a bound value can be personal data; only the accessors expose values.

What the view does not hold: filters from a QueryCustomizer and the ones the executor adds (keyset, child keys), and the filters of a fetch plan's child queries.