Lightweight testing library for jOOQ applications with automatic foreign key resolution
Testing with databases is hard. Setting up test data is tedious and error-prone:
// ❌ Traditional approach: 30+ lines of boilerplate
AuthorRecord author = dsl.newRecord(AUTHOR);
author.setId(1L);
author.setName("Test Author");
author.setEmail("test@example.com");
author.setBio("Some bio");
author.setRating(4.5);
author.setCreatedAt(LocalDateTime.now());
// ... 10 more fields
author.insert();
BookRecord book = dsl.newRecord(BOOK);
book.setId(1L);
book.setAuthorId(author.getId()); // Manual FK!
book.setTitle("Test Book");
book.setIsbn("123-456");
// ... 15 more fields
book.insert();
// ✅ With Joot: 1 line, automatic FK resolution
Book book = ctx.create(BOOK, Book.class).build();
// author is auto-created, all NOT NULL fields populated, FK wired automatically ✨Joot gives you:
- ✅ Zero boilerplate - focus on what matters in your tests
- ✅ Automatic FK resolution - no manual parent entity creation
- ✅ Smart defaults - generates realistic test data automatically
- ✅ Type-safe API - leverages jOOQ's generated code
- ✅ Factory definitions & traits - reusable templates with composable variations
- ✅ Factory inheritance - parent/child definitions with merging
- ✅ Build strategies - build without insert, inspect attributes
- ✅ Transient attributes - pass non-persisted data to callbacks
- ✅ Production-ready - 148 integration tests, 100% pass rate
Gradle:
dependencies {
testImplementation 'io.github.jtestkit:joot:0.9.0'
}Maven:
<dependency>
<groupId>io.github.jtestkit</groupId>
<artifactId>joot</artifactId>
<version>0.9.0</version>
<scope>test</scope>
</dependency>import io.github.jtestkit.joot.JootContext;
import org.jooq.DSLContext;
class MyTest {
DSLContext dsl = ...; // Your jOOQ DSLContext
JootContext ctx = JootContext.create(dsl);
@Test
void myTest() {
// Create entities with zero boilerplate!
Author author = ctx.create(AUTHOR, Author.class).build();
// Data cleanup is your responsibility
// (Use @Transactional, manual DELETE, or your preferred strategy)
}
}@Test
void shouldCreateBookWithAuthor() {
// ACT: Create book (author auto-created!)
Book book = ctx.create(BOOK, Book.class).build();
// ASSERT
assertThat(book.getAuthorId()).isNotNull();
assertThat(book.getTitle()).isNotNull(); // All NOT NULL fields populated
// Verify author was created
Author author = ctx.get(book.getAuthorId(), AUTHOR, Author.class);
assertThat(author).isNotNull();
}No need to manually create parent entities:
// Creating a book automatically creates its author
Book book = ctx.create(BOOK, Book.class).build();
// Creating an order automatically creates user AND product
Order order = ctx.create(ORDER, Order.class).build();
// Even deep hierarchies work automatically
// Book -> Author -> Publisher -> Address -> Country ✨All fields get sensible defaults based on type and constraints:
Book book = ctx.create(BOOK, Book.class).build();
// NOT NULL fields are auto-populated:
book.getTitle() // → "title_1" (field name as prefix!)
book.getIsbn() // → "isbn_1"
book.getAuthorId() // → 1L (auto-created author)
book.getPrice() // → random BigDecimal
book.getPublishedAt() // → LocalDateTime.now()
// Enum fields get first value (deterministic)
order.getStatus() // → OrderStatus.PENDING (always first from .values())
// UNIQUE fields get unique values automatically
book.getIsbn() // → "isbn_1", "isbn_2", "isbn_3" (auto-incremented)Override defaults for fields that matter in your test:
Book book = ctx.create(BOOK, Book.class)
.set(BOOK.TITLE, "1984")
.set(BOOK.PRICE, new BigDecimal("19.99"))
.build();
// Only title and price are explicit, everything else auto-generatedEnum fields are automatically handled - Joot uses the first value from enum.values() for deterministic behavior:
public enum OrderStatus {
PENDING, // ← This one is used automatically
CONFIRMED,
SHIPPED,
DELIVERED
}
// Automatic enum handling
Order order = ctx.create(ORDER, Order.class).build();
assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING); // ✅ Always first value
// Works with jOOQ EnumConverter
Task task = ctx.create(TASK, Task.class).build();
assertThat(task.getStatus()).isEqualTo(TaskStatus.PENDING); // ✅ First enumWhy first value?
- ✅ Deterministic - tests behave the same every time
- ✅ Predictable - you always know what to expect
- ✅ Debuggable - no random behavior
Need a different value?
// Explicit value
Order shipped = ctx.create(ORDER, Order.class)
.set(ORDER.STATUS, OrderStatus.SHIPPED)
.build();
// Custom generator
ctx.registerGenerator(OrderStatus.class, (len, unique) -> OrderStatus.CONFIRMED);// By default, nullable fields are populated (production-like data)
Author author = ctx.create(AUTHOR, Author.class).build();
assertThat(author.getBio()).isNotNull(); // ✅ Generated
assertThat(author.getWebsite()).isNotNull(); // ✅ Generated
// Disable for minimal data
Author minimal = ctx.create(AUTHOR, Author.class)
.generateNullables(false)
.build();
assertThat(minimal.getBio()).isNull(); // ✅ NULL
assertThat(minimal.getName()).isNotNull(); // ✅ Still generated (NOT NULL)Joot intelligently handles circular references:
// Self-reference: category.parent_id → category.id
Category root = ctx.create(CATEGORY, Category.class).build();
assertThat(root.getParentId()).isNotNull(); // Parent auto-created
// Circular FK: users ↔ team
Team team = ctx.create(TEAM, Team.class).build();
// Joot breaks the cycle automatically ✅// Retrieve created entities by PK
Author author = ctx.get(1L, AUTHOR, Author.class);Define reusable defaults for your entities:
// Define once
ctx.define(AUTHOR, f -> {
f.set(AUTHOR.NAME, "Isaac Asimov");
f.set(AUTHOR.COUNTRY, "US");
});
// Use everywhere — defaults applied automatically
Author author = ctx.create(AUTHOR, Author.class).build();
assertThat(author.getName()).isEqualTo("Isaac Asimov");
// Override when needed
Author other = ctx.create(AUTHOR, Author.class)
.set(AUTHOR.NAME, "Arthur Clarke")
.build();
assertThat(other.getName()).isEqualTo("Arthur Clarke");
assertThat(other.getCountry()).isEqualTo("US"); // from definitionNamed variations that compose on top of definitions:
ctx.define(AUTHOR, f -> {
f.set(AUTHOR.NAME, "Default Author");
f.set(AUTHOR.COUNTRY, "US");
f.trait("european", t -> t.set(AUTHOR.COUNTRY, "DE"));
f.trait("renamed", t -> t.set(AUTHOR.NAME, "Special Author"));
});
// Apply single trait
Author eu = ctx.create(AUTHOR, Author.class)
.trait("european")
.build();
assertThat(eu.getCountry()).isEqualTo("DE");
// Compose multiple traits (applied in order)
Author special = ctx.create(AUTHOR, Author.class)
.trait("european")
.trait("renamed")
.build();
assertThat(special.getCountry()).isEqualTo("DE");
assertThat(special.getName()).isEqualTo("Special Author");
// Explicit .set() always wins over traits
Author jp = ctx.create(AUTHOR, Author.class)
.trait("european")
.set(AUTHOR.COUNTRY, "JP")
.build();
assertThat(jp.getCountry()).isEqualTo("JP");Unknown trait names throw IllegalArgumentException with a list of available traits.
Predictable, auto-incrementing values for fields:
ctx.sequence(AUTHOR.EMAIL, n -> "author" + n + "@test.com");
Author a1 = ctx.create(AUTHOR, Author.class).build();
Author a2 = ctx.create(AUTHOR, Author.class).build();
// a1.getEmail() == "author1@test.com"
// a2.getEmail() == "author2@test.com"
// Works with any type
ctx.sequence(BOOK.PAGES, n -> (int) (n * 100));
// 100, 200, 300, ...Execute logic before/after entity insertion:
ctx.define(AUTHOR, f -> {
f.set(AUTHOR.NAME, "Author");
f.beforeCreate(record -> {
// Modify record before INSERT
record.set(AUTHOR.NAME, record.get(AUTHOR.NAME).toUpperCase());
});
f.afterCreate(record -> {
// Create related entities after INSERT
Object authorId = record.get(AUTHOR.ID);
ctx.create(BOOK, Book.class)
.set(BOOK.AUTHOR_ID, (UUID) authorId)
.build();
});
});Trait callbacks compose with base callbacks (base runs first, then trait):
ctx.define(AUTHOR, f -> {
f.afterCreate(r -> log("base"));
f.trait("logged", t -> t.afterCreate(r -> log("trait")));
});
ctx.create(AUTHOR, Author.class).trait("logged").build();
// logs: "base", then "trait"Create multiple entities at once with .times():
// Create 5 authors
List<Author> authors = ctx.create(AUTHOR, Author.class).times(5);
// With per-item customization
List<Author> authors = ctx.create(AUTHOR, Author.class)
.times(3, (builder, i) -> builder.set(AUTHOR.NAME, "Author " + i));
// "Author 0", "Author 1", "Author 2"
// Works with traits and definitions
List<Author> europeans = ctx.create(AUTHOR, Author.class)
.trait("european")
.times(10);Define parent/child factory definitions with automatic merging:
// Parent definition
ctx.define("baseAuthor", AUTHOR, f -> {
f.set(AUTHOR.NAME, "Base Author");
f.set(AUTHOR.COUNTRY, "US");
f.trait("european", t -> t.set(AUTHOR.COUNTRY, "DE"));
f.afterCreate(record -> log.info("Created author"));
});
// Child inherits defaults, traits, and callbacks
ctx.define(AUTHOR, f -> {
f.parent("baseAuthor");
f.set(AUTHOR.NAME, "Child Author"); // overrides parent's NAME
// COUNTRY="US" inherited, "european" trait inherited, afterCreate inherited
});
Author author = ctx.create(AUTHOR, Author.class).build();
// name="Child Author" (child), country="US" (parent)
Author eu = ctx.create(AUTHOR, Author.class).trait("european").build();
// country="DE" (inherited trait)Merge rules:
- Defaults: child overrides parent
- Generators: child overrides parent
- Traits: child overrides parent traits with same name
- Callbacks: concatenated (parent first, then child)
Missing parent definitions throw IllegalStateException. Cyclic inheritance (A → B → A) is detected and throws.
Control how entities are built:
// Build without inserting into database (no FK auto-creation, no callbacks)
AuthorRecord record = ctx.createRecord(AUTHOR)
.set(AUTHOR.NAME, "Preview")
.buildWithoutInsert();
// record is populated but NOT in the database
// Inspect resolved attributes as a map
Map<Field<?>, Object> attrs = ctx.createRecord(AUTHOR)
.trait("european")
.buildAttributes();
// attrs contains {AUTHOR.NAME -> "...", AUTHOR.COUNTRY -> "DE", ...}Pass non-persisted data to lifecycle callbacks:
ctx.define(AUTHOR, f -> {
f.set(AUTHOR.NAME, "Author");
f.afterCreate((record, transients) -> {
int bookCount = transients.getOrDefault("bookCount", Integer.class, 0);
UUID authorId = (UUID) record.get(AUTHOR.ID);
for (int i = 0; i < bookCount; i++) {
ctx.create(BOOK, Book.class).set(BOOK.AUTHOR_ID, authorId).build();
}
});
});
// Pass transient values at build time
Author author = ctx.create(AUTHOR, Author.class)
.transientAttr("bookCount", 3)
.build();
// 3 books auto-created via callbackRegister custom generators for specific fields or types:
// For a specific field
ctx.registerGenerator(USER.EMAIL, new EmailGenerator());
// → "test-1@example.com", "test-2@example.com"
// For a type
ctx.registerGenerator(LocalDate.class, new LocalDateGenerator());
// For enums (override default)
ctx.registerGenerator(OrderStatus.class, (len, unique) -> OrderStatus.SHIPPED);
// Per-builder override
Author author = ctx.create(AUTHOR, Author.class)
.withGenerator(AUTHOR.NAME, (len, unique) -> "Custom Name")
.build();JootContext is created from a jOOQ DSLContext. Choose the approach based on your test lifecycle needs:
Create JootContext in @BeforeEach for test isolation:
class MyTest extends BaseIntegrationTest {
JootContext ctx;
@BeforeEach
void setup() {
ctx = JootContext.create(dsl);
// Optional: register custom generators
ctx.registerGenerator(MyType.class, (len, unique) -> ...);
}
@Test
void myTest() {
Author author = ctx.create(AUTHOR, Author.class).build();
}
}Pros:
- ✅ Complete isolation between tests
- ✅ No shared state
- ✅ Works with any test lifecycle (PER_METHOD, PER_CLASS)
Cons:
⚠️ Custom generators need re-registration (but this is fast ~0.01ms)
Best for: Most use cases. Context creation is lightweight (~0.1ms).
Create JootContext in @BeforeAll when using static DSLContext:
@TestInstance(Lifecycle.PER_CLASS)
class MyTest extends BaseIntegrationTest {
static JootContext ctx;
@BeforeAll
static void setup() {
ctx = JootContext.create(dsl);
// Register generators once
ctx.registerGenerator(MyType.class, (len, unique) -> ...);
}
@Test
void myTest() {
Author author = ctx.create(AUTHOR, Author.class).build();
}
}Pros:
- ✅ Custom generators registered once
- ✅ Slightly faster (negligible difference)
Cons:
⚠️ Shared state: Custom generators registered in one test affect others⚠️ Requires staticDSLContextandPER_CLASSlifecycle
Best for: Tests with common generator configuration that doesn't change.
JootContext is mostly stateless, but GeneratorRegistry (custom generators) is shared state. If tests register different generators, use Approach 1.
Joot respects database-generated values:
// SERIAL, AUTO_INCREMENT, IDENTITY columns
Article article = ctx.create(ARTICLE, Article.class).build();
assertThat(article.getId()).isNotNull(); // Generated by DB
// DEFAULT values (e.g., DEFAULT CURRENT_TIMESTAMP)
assertThat(article.getPublishedAt()).isNotNull(); // Set by DBclass BookServiceTest extends BaseIntegrationTest {
private JootContext ctx;
@Autowired
private BookService bookService;
@BeforeEach
void setup() {
ctx = JootContext.create(dsl);
}
@Test
void shouldPublishBook() {
// ARRANGE: Create book with specific title
Book book = ctx.create(BOOK, Book.class)
.set(BOOK.TITLE, "1984")
.set(BOOK.PUBLISHED, false)
.build();
// Author, publisher, etc. auto-created ✨
// ACT
bookService.publish(book.getId());
// ASSERT
Book updated = ctx.get(book.getId(), BOOK, Book.class);
assertThat(updated.getPublished()).isTrue();
}
@Test
void shouldFindBooksByAuthor() {
// ARRANGE: Create author with multiple books
Author author = ctx.create(AUTHOR, Author.class).build();
ctx.create(BOOK, Book.class)
.set(BOOK.AUTHOR_ID, author.getId())
.set(BOOK.TITLE, "Book 1")
.build();
ctx.create(BOOK, Book.class)
.set(BOOK.AUTHOR_ID, author.getId())
.set(BOOK.TITLE, "Book 2")
.build();
// ACT
List<Book> books = bookService.findByAuthor(author.getId());
// ASSERT
assertThat(books).hasSize(2);
assertThat(books).extracting(Book::getTitle)
.containsExactlyInAnyOrder("Book 1", "Book 2");
}
}Joot comes with smart generators for common types:
| Type | Example Output | Notes |
|---|---|---|
String |
"name_1", "email_2" |
Uses field name as prefix |
Integer/Long |
1, 2, 3 |
Auto-incremented |
UUID |
UUID.randomUUID() |
Unique UUIDs |
Boolean |
true/false |
Random |
LocalDateTime |
LocalDateTime.now() |
Current timestamp |
LocalDate |
LocalDate.now() |
Current date |
BigDecimal |
Random value | Suitable for prices |
Enum |
First value | enum.values()[0] (deterministic) |
Strings adapt to column constraints:
// VARCHAR(5) → "a1", "b2", "c3"
// VARCHAR(10) → "n1", "e2" (first char + counter)
// VARCHAR(20) → "name_1", "title_2"
// VARCHAR(255) → "author_name_12345678"
// TEXT → "bio_1", "description_2"
// UNIQUE fields get unique values automatically
// author.email = "email_1", "email_2", "email_3"// Create context
JootContext ctx = JootContext.create(dsl);
// Factory definitions
ctx.define(TABLE, f -> { ... });
ctx.define("name", TABLE, f -> { f.parent("parentName"); ... });
// Sequences
ctx.sequence(FIELD, n -> "value" + n);
// Create entities
<T> T create(Table<?> table, Class<T> pojoClass).build();
<R extends Record> R createRecord(Table<R> table).build();
// Data access
<T> T get(Object pk, Table<?> table, Class<T> pojoClass);
// Nullable fields control
ctx.generateNullables(false); // globally skip nullable fields
// Custom generators
<T> void registerGenerator(Field<T> field, ValueGenerator<T> generator);
<T> void registerGenerator(Class<T> type, ValueGenerator<T> generator);// Set explicit values
builder.set(FIELD, value)
// Apply trait from definition
builder.trait("traitName")
// Control nullable generation
builder.generateNullables(boolean)
// Per-builder generator
builder.withGenerator(FIELD, generator)
// Batch creation
List<T> times(int count)
List<T> times(int count, (builder, index) -> { ... })
// Transient attributes
builder.transientAttr("name", value)
// Build strategies
T build() // insert + return
T buildWithoutInsert() // no insert, no FK auto-creation
Map<Field<?>, Object> buildAttributes() // resolved values map- Java: 17+
- jOOQ: 3.13+
- Database: Any jOOQ-supported database (PostgreSQL, MySQL, H2, etc.)
Contributions are welcome! Please feel free to submit a Pull Request.
git clone https://github.com/jtestkit/joot.git
cd joot
./gradlew testMIT License - see LICENSE file for details
- Built on top of jOOQ - excellent type-safe SQL library
- Inspired by TestContainers - great testing philosophy
- Special thanks to the jOOQ community
Joot is designed with these principles:
- Zero boilerplate - tests should focus on business logic, not data setup
- Production-like data - tests with realistic data catch more bugs
- Type-safety - leverage jOOQ's compile-time safety
- Simplicity - one dependency, zero configuration
- Flexibility - sensible defaults, full control when needed
- Issues: GitHub Issues
- Discussions: GitHub Discussions
Happy Testing! 🚀