Codion applications are tested in layers, matching how they are built:

  • Domain testing — entity definitions, relationships and database operations, via DomainTest

  • Model testing — application logic in edit and table models, headless

  • Integration testing — workflows across entities and models

Since the model layer has no UI dependency, everything below the panels is testable without showing a window.

1. Test configuration

The test user is configured via the codion.test.user system property:

./gradlew test -Dcodion.test.user=scott:tiger

2. Domain testing

The DomainTest base class verifies an entity definition against the database with a single method call per entity type: a randomly populated entity is inserted, selected, modified, updated and deleted, in a transaction which is rolled back — exercising the definition, its column mappings, primary key generation and foreign key relationships, without leaving a trace.

public class ChinookTest extends DomainTest {

  public ChinookTest() {
    super(new ChinookImpl(), ChinookEntityFactory::new);
  }

  @Test
  void album() {
    test(Album.TYPE);
  }

  @Test
  void artist() {
    test(Artist.TYPE);
  }
}

2.1. Custom entity factory

The random entities are created by an EntityFactory — extend DefaultEntityFactory to control the values of specific attributes, for entities with constraints random values cannot satisfy:

private static final class ChinookEntityFactory extends DefaultEntityFactory {

  private ChinookEntityFactory(EntityConnection connection) {
    super(connection);
  }

  @Override
  public void modify(Entity entity) {
    super.modify(entity);
    if (entity.type().equals(Album.TYPE)) {
      entity.set(Album.TAGS, List.of("tag_one", "tag_two", "tag_three"));
    }
  }

  @Override
  protected <T> T value(Attribute<T> attribute) {
    if (attribute.equals(Album.TAGS)) {
      return (T) List.of("tag_one", "tag_two");
    }

    return super.value(attribute);
  }
}

2.2. Testing database functions

Database functions and procedures are tested through a connection, in a transaction rolled back in a finally block — the standard pattern for any test modifying data:

@Test
void randomPlaylist() {
  EntityConnection connection = connection();
  connection.startTransaction();
  try {
    Entity genre = connection.selectSingle(Genre.NAME.equalTo("Metal"));
    int noOfTracks = 10;
    String playlistName = "MetalPlaylistTest";
    RandomPlaylistParameters parameters = new RandomPlaylistParameters(playlistName, noOfTracks, List.of(genre));
    Entity playlist = connection.execute(Playlist.RANDOM_PLAYLIST, parameters);
    assertEquals(playlistName, playlist.get(Playlist.NAME));
    List<Entity> playlistTracks = connection.select(PlaylistTrack.PLAYLIST_FK.equalTo(playlist));
    assertEquals(noOfTracks, playlistTracks.size());
    playlistTracks.stream()
            .map(playlistTrack -> playlistTrack.get(PlaylistTrack.TRACK_FK))
            .forEach(track -> assertEquals(genre, track.get(Track.GENRE_FK)));
  }
  finally {
    connection.rollbackTransaction();
  }
}

3. Model testing

Application logic in edit and table models is tested by constructing the model with a local connection provider and exercising it directly:

package is.codion.demos.world.model;

import is.codion.common.utilities.user.User;
import is.codion.demos.world.domain.WorldImpl;
import is.codion.demos.world.domain.api.World.Country;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.db.local.LocalEntityConnectionProvider;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

public class CountryEditModelTest {

  private static final User UNIT_TEST_USER =
          User.parse(System.getProperty("codion.test.user", "scott:tiger"));

  @Test
  void averageCityPopulation() {
    try (EntityConnectionProvider connectionProvider = createConnectionProvider()) {
      CountryEditModel countryEditModel = new CountryEditModel(connectionProvider);
      countryEditModel.editor().entity().set(connectionProvider.connection().selectSingle(
              Country.NAME.equalTo("Afghanistan")));
      assertEquals(583_025, countryEditModel.averageCityPopulation().get());
      countryEditModel.editor().entity().defaults();
      assertNull(countryEditModel.averageCityPopulation().get());
    }
  }

  private static EntityConnectionProvider createConnectionProvider() {
    return LocalEntityConnectionProvider.builder()
            .domain(new WorldImpl())
            .user(UNIT_TEST_USER)
            .build();
  }
}

Master-detail behavior tests the same way — select in the master, assert the detail:

package is.codion.demos.chinook.model;

import is.codion.common.utilities.user.User;
import is.codion.demos.chinook.domain.ChinookImpl;
import is.codion.demos.chinook.domain.api.Chinook.Album;
import is.codion.demos.chinook.domain.api.Chinook.Track;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.db.local.LocalEntityConnectionProvider;
import is.codion.framework.domain.entity.Entity;
import is.codion.framework.domain.entity.exception.EntityValidationException;
import is.codion.swing.framework.model.SwingEntityTableModel;

import org.junit.jupiter.api.Test;

import java.util.List;

import static is.codion.framework.db.EntityConnection.Update.where;
import static org.junit.jupiter.api.Assertions.assertEquals;

public final class AlbumModelTest {

  private static final String MASTER_OF_PUPPETS = "Master Of Puppets";

  @Test
  void albumRefreshedWhenTrackRatingIsUpdated() throws EntityValidationException {
    try (EntityConnectionProvider connectionProvider = createConnectionProvider()) {
      EntityConnection connection = connectionProvider.connection();
      connection.startTransaction();

      // Initialize all the tracks with an inital rating of 8
      Entity masterOfPuppets = connection.selectSingle(Album.TITLE.equalTo(MASTER_OF_PUPPETS));
      connection.update(where(Track.ALBUM_FK.equalTo(masterOfPuppets))
              .set(Track.RATING, 8)
              .build());
      // Re-select the album to get the updated rating, which is the average of the track ratings
      masterOfPuppets = connection.selectSingle(Album.TITLE.equalTo(MASTER_OF_PUPPETS));
      assertEquals(8, masterOfPuppets.get(Album.RATING));

      // Create our AlbumModel and configure the query condition
      // to populate it with only Master Of Puppets
      AlbumModel albumModel = new AlbumModel(connectionProvider);
      SwingEntityTableModel albumTableModel = albumModel.tableModel();
      albumTableModel.query().condition().get(Album.TITLE).set().equalTo(MASTER_OF_PUPPETS);
      albumTableModel.items().refresh();
      assertEquals(1, albumTableModel.items().size());

      List<Entity> modifiedTracks = connection.select(Track.ALBUM_FK.equalTo(masterOfPuppets)).stream()
              .peek(track -> track.set(Track.RATING, 10))
              .toList();

      // Update the tracks using the edit model
      albumModel.detail().get(Track.TYPE).editor().update(modifiedTracks);

      // Which should trigger the refresh of the album in the Album model
      // now with the new rating as the average of the track ratings
      assertEquals(10, albumTableModel.items().included().get(0).get(Album.RATING));

      connection.rollbackTransaction();
    }
  }

  private static EntityConnectionProvider createConnectionProvider() {
    return LocalEntityConnectionProvider.builder()
            .domain(new ChinookImpl())
            .user(User.parse("scott:tiger"))
            .build();
  }
}

4. Integration testing

Complete workflows, such as report generation, follow the same shape:

package is.codion.demos.world.model;

import is.codion.common.model.worker.ProgressWorker.ProgressReporter;
import is.codion.common.reactive.value.Value;
import is.codion.common.utilities.user.User;
import is.codion.demos.world.domain.WorldImpl;
import is.codion.demos.world.domain.api.World.City;
import is.codion.demos.world.domain.api.World.Country;
import is.codion.framework.db.EntityConnection;
import is.codion.framework.db.EntityConnectionProvider;
import is.codion.framework.db.local.LocalEntityConnectionProvider;
import is.codion.framework.domain.entity.Entity;
import is.codion.framework.domain.entity.attribute.Attribute;

import net.sf.jasperreports.engine.JRDataSource;
import net.sf.jasperreports.engine.JRException;
import net.sf.jasperreports.engine.JRField;
import net.sf.jasperreports.engine.base.JRBaseField;
import org.junit.jupiter.api.Test;

import java.util.List;

import static is.codion.framework.db.EntityConnection.Select.where;
import static is.codion.framework.domain.entity.OrderBy.ascending;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

public final class CountryReportDataSourceTest {

  private static final User UNIT_TEST_USER =
          User.parse(System.getProperty("codion.test.user", "scott:tiger"));

  @Test
  void iterate() throws JRException {
    try (EntityConnectionProvider connectionProvider = createConnectionProvider()) {
      Value<Integer> progressCounter = Value.nullable();
      Value<String> publishedValue = Value.nullable();
      ProgressReporter<String> progressReporter = new ProgressReporter<>() {
        @Override
        public void report(int progress) {
          progressCounter.set(progress);
        }

        @Override
        public void publish(String... chunks) {
          publishedValue.set(chunks[0]);
        }
      };

      EntityConnection connection = connectionProvider.connection();
      List<Entity> countries =
              connection.select(where(Country.NAME.in("Denmark", "Iceland"))
                      .orderBy(ascending(Country.NAME))
                      .build());
      CountryReportDataSource countryReportDataSource = new CountryReportDataSource(countries.iterator(), connection, progressReporter);
      assertThrows(IllegalStateException.class, countryReportDataSource::cityDataSource);

      countryReportDataSource.next();
      assertEquals("Denmark", countryReportDataSource.getFieldValue(field(Country.NAME)));
      assertEquals("Europe", countryReportDataSource.getFieldValue(field(Country.CONTINENT)));
      assertEquals("Nordic Countries", countryReportDataSource.getFieldValue(field(Country.REGION)));
      assertEquals(43094d, countryReportDataSource.getFieldValue(field(Country.SURFACEAREA)));
      assertEquals(5330000, countryReportDataSource.getFieldValue(field(Country.POPULATION)));
      assertThrows(JRException.class, () -> countryReportDataSource.getFieldValue(field(City.LOCATION)));

      JRDataSource denmarkCityDataSource = countryReportDataSource.cityDataSource();
      denmarkCityDataSource.next();
      assertEquals("København", denmarkCityDataSource.getFieldValue(field(City.NAME)));
      assertEquals(495699, denmarkCityDataSource.getFieldValue(field(City.POPULATION)));
      assertThrows(JRException.class, () -> denmarkCityDataSource.getFieldValue(field(Country.REGION)));
      denmarkCityDataSource.next();
      assertEquals("Århus", denmarkCityDataSource.getFieldValue(field(City.NAME)));

      countryReportDataSource.next();
      assertEquals("Iceland", countryReportDataSource.getFieldValue(field(Country.NAME)));

      JRDataSource icelandCityDataSource = countryReportDataSource.cityDataSource();
      icelandCityDataSource.next();
      assertEquals("Reykjavík", icelandCityDataSource.getFieldValue(field(City.NAME)));

      assertEquals(2, progressCounter.get());
      assertEquals("Iceland", publishedValue.get());
    }
  }

  private static EntityConnectionProvider createConnectionProvider() {
    return LocalEntityConnectionProvider.builder()
            .domain(new WorldImpl())
            .user(UNIT_TEST_USER)
            .build();
  }

  private static JRField field(Attribute<?> attribute) {
    return new TestField(attribute.name());
  }

  private static final class TestField extends JRBaseField {

    private TestField(String name) {
      this.name = name;
    }
  }
}

5. Practices

  1. Run DomainTest over every entity type — it is one line per entity and catches definition/schema drift immediately.

  2. Transactions with rollback keep tests isolated and repeatable — no test data cleanup.

  3. Test observable behavior — model state is exposed as Value/State/Event; assert on those, the same surface the UI binds to.

  4. Load test early — see application load testing for exercising the application under concurrent users.