testcontainers / testcontainers-java

Testcontainers is a Java library that supports JUnit tests, providing lightweight, throwaway instances of common databases, Selenium web browsers, or anything else that can run in a Docker container.
https://testcontainers.org
MIT License
8.02k stars 1.65k forks source link

[Enhancement]: Basic Hamcrest Matchers for Databases #9228

Closed beargiles closed 1 month ago

beargiles commented 1 month ago

Module

Core

Proposal

There are three reasons for this proposed enhancement.

First, it's well known that the tested code should not also be used for test verification. If this is done it is possible to write code that is self-consistent but not interoperable with any other implementation. The risk of this happening is much lower if different parties implement the library used to verify the test results, esp. if that library is used by multiple parties.

(Yes, this has happened to me once.)

Second, it's easy for a trivial error to cause database tests to blow chunks. The sheer number of failures can make it difficult to identify the cause. This is why the test frameworks include assume() methods in addition to assert() methods. The former are used to check preconditions and will disable, not fail, a test if they're not satisfied. The same verification methods can be used in both assume() and assert() calls - but unless the development team is disciplined this can easily result in unreadable code.

Finally it's a small conceptual leap from a method called to verify preconditions to a method called to create those preconditions if required.

Code Contribution

I hope to make a PR with a proof-of-concept implementation in the next few days. It's always easier to discuss enhancements when you have a working prototype even if it's far from being ready to merge.

Restricted Scope

It goes without saying that this could become an endless pit - but it's not inevitable if we remember to remain focused on the fact that these methods would only be used to check pre- and post-conditions, and later to establish pre-conditions and possibly to revert the test's changes.

Database Hamcrest Matchers

With this in mind we can identify a handful of custom Hamcrest matchers that can be used by all relational databases that support the standard JDBC API.

Usage

This example is a little handwavy - I've implemented this functionality in the past but will reimplement the functionality based on my understanding of Hamcrest TypeSafeMatcher<> etc. instead of referring to any prior code. I'll also be applying a deeper understanding of functional interfaces.

public class CrudTest {

    private static final String TEST_TABLE_NAME = "test";
    private static final String TEST_QUERY = "select id from test";
    private static final int TEST_RECORD_ID = 10;

   @Resource
   JdbcDatabaseContainer db;

    @Test
    public void testCreateTable() throws SQLException {
        assumeThat(not(db.tableExists(TEST_TABLE_NAME)));

        // call method that creates a table

        assertThat(db.tableExists(TEST_TABLE_NAME));
    }

    Predicate<ResultSet> idExists(ResultSet rs, Object...) throws SQLException {
        return rs.getInt("id").equals((Integer) id);
    }

    @Test 
    public void testCreateRecord() throws SQLException {
        assumeThat(not(db.recordExists(TEST_QUERY, this::recordIdExists, TEST_RECORD_ID)));

        // call method that creates a record

        assertThat(db.recordExists(TEST_QUERY, this::recordIdExists, TEST_RECORD_ID));
    } 
}

Implementation

The initial custom Hamcrest matchers will look something like this

public class TableMatcher extends TypeSafeMatcher<JdbcDatabaseContainer> {

    public TableMatcher(String tableName) { ... }

    @Override
    public void describeMismatchSafely(JdbcDatabaseContainer db, Description description) { ... }

    @Override
    public void describeTo(Description description) { ... }

    @Override
    public boolean matchesSafely (JdbcDatabaseContainer db) { ... }

    // these are the methods available to the test framework

    public static Matcher<JdbcDatabaseContainer> tableExists(String tableName) { ... }

    // this allows us to verify the table has the expected schema
    public static Matcher<JdbcDatabaseContainer> tableMatches(String tableName, Predicate<ResultSet> predicate, Object... args) { ... }
}

and

public class RecordMatcher extends TypeSafeMatcher<JdbcDatabaseContainer> {

    public RecordMatcher(String tableName) { ... }

    @Override
    public void describeMismatchSafely(JdbcDatabaseContainer db, Description description) { ... }

    @Override
    public void describeTo(Description description) { ... }

    @Override
    public boolean matchesSafely (JdbcDatabaseContainer db) { ... }

    // these are the methods available to the test framework

    // do ANY records exist?
    public static Matcher<JdbcDatabaseContainer> recordExists(String query);

    // does exactly one record exist?
    public static Matcher<JdbcDatabaseContainer> uniqueRecordExists(String query);

    // do ANY records match? Intended to check anything
    //  public static Matcher<JdbcDatabaseContainer> recordMatches(String query, Predicate<ResultSet> predicate, Object... args) { ... }

    // does exactly one record match? Intended to check anything
    //  public static Matcher<JdbcDatabaseContainer> uniqueRecordMatches(String query, Predicate<ResultSet> predicate, Object... args) { ... }
}

There are a few open questions, e.g.,

The benefit of curried predicates is that we can then add methods that allow callbacks, e.g., it's not hard to imagine an enhancement where the method doesn't just match tables or records - it active involves a callback for arbitrary code to be executed on the matching records. This could be incorpated in the Predicate, of course, but that blurs responsibilities. In ths specific case we could probably add a Consumer<> before the Predicate<> but we again face the question of how we could provide additional information to that consumer, if desired. Fortunately we can punt this question if our initial approach does not include the Object... args parameter.

The actual implementation is straightforward for any JDBC database. The TableMatcher only requires the DatabaseMetaData information. The RecordMatcher will only requires that information plus SELECT privileges.

JdbcDatabaseContainer Modifications

The modifications to JdbcDatabaseContainer are modest.

public abstract class JdbcDatabaseContainer<SELF...> {

    public tableExists(String tableName) throws SQLException {
        return TableMatcher.tableExists(tableName);
    }

    public tableMatches(String tableName, Predicate<ResultSet> predicate, Object... args) throws SQLException {
        return TableMatcher.tableMatches(tableName, predicate, args);
    }

    public recordExists(String query, Predicate<ResultSet> predicate, Object... args) throws SQLException {
        return RecordMatcher.recordExists(query, predicate, args);
    }

    ...

Needless to say individual modules could overwrite these methods or add their own.

beargiles commented 1 month ago

Semi-related - the proposed LDAP module will also include custom Hamcrest matchers. This will be a much deeper set of matchers since we know the main use of LDAP involves user account management and everyone uses a standard set of schemas to hold the information.

eddumelendez commented 1 month ago

Hi @beargiles, thanks for the proposal but the Testcontainers responsibility is about container initialization.