Testing

Marginalia's tests are integration tests first: most of them start the real Spring XML configuration against a throwaway SQLite database and talk to a fake OpenAI-compatible server. They need no network, no model and no API key, and they run in CI on every push and pull request. This page explains the test infrastructure and how to add tests.

All test code is in marginalia/src/test/java/com/github/enerccio/marginalia/; paths below are relative to it.

Running the tests

From marginalia/:

mvn test                                     # all tests
mvn test -Dtest=LorebookActivationTest       # one class
mvn test -Dtest='Backup*Test'                # a pattern
mvn test -Dtest='MacroRenderingTest#name'    # one method

Surefire is configured in pom.xml with:

Setting Why
-XX:+EnableDynamicAgentLoading -Djdk.attach.allowAttachSelf=true RuntimeInstrumentationTest installs the ByteBuddy agent into the test JVM.
marginalia.test.home=target/test-home Folder for the test databases (see The application folder).
forkCount=${test.forks} (default 3), reuseForks=true The test classes are spread over that many JVMs, one class at a time in each. Every JVM has its own Spring context and so its own database: SQLite lets one writer in at a time, and the @Configurable aspect (the @Autowired fields of the UI classes) is one for the JVM and bound to one Spring context, so threads of one JVM with a context each do not work. -Dtest.forks=1 runs the classes one after another (times below).

How long mvn test takes (about 3,000 tests), with the machine, as the time depends on it. Add a row for yours: the Total time line of Maven, once with -Dtest.forks=1 and once as it is.

Machine -Dtest.forks=1 3 JVMs (default) Memory of the 3 JVMs
Intel Core i7-10700K (8 cores, 16 threads), 32 GB RAM, NVMe SSD (btrfs on LUKS), Linux, JDK 26 19 min 7 min 6.5 GB peak
Apple M4 (10 cores), 16 GB RAM, internal SSD, macOS, JDK 26 2 min 20 s 1 min 27 s 1.8 GB peak

After a mvn install or mvn package that compiled with plain javac, mvn test may skip the AspectJ compile of the classes that did not change and leave them unwoven: a @Configurable class (a command, a UI class, an anonymous subclass in a test such as SearchReplaceCommandsTest) then has null in its @Autowired fields. touch the source of the failing test, or build with clean; it is not a bug of the code.

A class can run after any other in its JVM, and the classes of a JVM share its database, so a test must not count on what the database holds (see The shared database): create data with uniqueName and compare with what was there before.

Reports are written to target/surefire-reports/; the CI job uploads them as an artifact when a test fails.

In the IDE

Tests run from IntelliJ IDEA like any JUnit test, with two conditions:

  • the classes must be compiled with AspectJ (see Building from source), otherwise UI classes and @Configurable beans miss their dependencies;

  • add -XX:+EnableDynamicAgentLoading -Djdk.attach.allowAttachSelf=true to the VM options of the JUnit run configuration template if you run RuntimeInstrumentationTest.

Without marginalia.test.home the tests fall back to target/test-home relative to the working directory, so run them with marginalia/ as the working directory (IntelliJ's default for the module).

Libraries

Library Used for
JUnit Jupiter 6 Test engine, @Test, @BeforeEach, @TempDir...
AssertJ All assertions (assertThat(...)) - use it instead of JUnit's Assertions.
Spring Test @SpringJUnitWebConfig, context caching, @DirtiesContext.
JDK HttpServer The fake LLM server (test/llm/MockLLMServer).

There is no mocking library. Services are tested through the real Spring beans and the real database; the only fake is the model server.

What is covered

Package Tests What they check
test TestBaseSmokeTest The test infrastructure itself: isolated folder, session user, mock LLM wiring.
test/llm MockLLMServerTest The fake OpenAI server against the real openai-java client.
statistics ManuscriptStatisticServiceTest, WordCountingTest, PartDeleteCountingTest, TokenCountingTest, AgentStatisticsTest, ActivityTrackerTest, TimeUnionTest, GenerationTimeTest, ManuscriptSizeTest, SizeSeriesTest, BookDataStatisticsTest, StatisticRangeTest, StatisticChartsTest, StatisticReportServiceTest, StatisticsBackfillMigrationTest, StatisticsReconstructionTest, WritingActivityTest The writing statistics: what is counted where (the versions of a part, the deletes, the tokens of a request, the agents' side), the stretches of time and their union (the tracker with a fake clock, a generation, the linger, a suspended computer), the size of a book and its growth, that clones, imports and restores count nothing, the report, the rebuilt history (one test per rule, and the migration on a database), the configurations of the charts, and the days worked on, the streaks and the goal.
crud *CrudTest (ExtensionAICrudTest for the AI of an extension), OwnedCrudContract, ExtendableCrudContract Create, update, soft and hard delete, owner isolation and extended attributes of every entity.
db FlywayMigrationTest, ConcurrentTransactionsTest, FulltextMigrationTest, OriginalResponseMigrationTest, PermissionsMigrationTest, DatabaseRestoreCheckTest, PreMigrationBackupTest Migrations on an empty, a V1 and a pre-Flyway database, followed by Hibernate schema validation (FlywayMigrationTest also checks that V9 keeps manuscripts.description in extendedContent); FulltextMigrationTest runs the app migration that fills _fulltext of existing rows on the Spring context, OriginalResponseMigrationTest the one that copies response into originalResponse (deleted parts too, modification kept); the check of a database before a restore and the fall back to the previous database when a restore fails on start (no Spring context); PreMigrationBackupTest checks the copy Configuration.resolveDb saves before an existing database is migrated (an older version, no history table, nothing for a current or new database, only the newest three kept).
domain HelpAssistantServiceTest, HelpAdminDataTest, ProblemLogTest, UiProblemsTest, ExtendableEntityListenerTest, ExtendedContentCompressionTest, FulltextQueryTest, ChatMessageSearchTest, TokenLimitsTest, AISharingTest, UsageServiceTest, AILimitServiceTest, ExtensionAIFactoryTest, CronScheduleTest, SillyTavernEntryConverterTest, TemplateHelpersTest, OsgiServiceImplTest, PermissionServiceTest Smaller units: extended attribute serialization, token limits, cron parsing, SillyTavern conversion; FulltextQueryTest and ChatMessageSearchTest cover the full-text query (words, phrases, *, escaping) and the search over all branches with the choice of the branch that shows a part (Spring context). HelpAssistantServiceTest covers the help assistant against the mock LLM: only administrators set it up, with an AI of their own, the AI is looked up at every request (deleted, or its owner no longer an administrator: unavailable), who pays (limits checked and usage counted only for a shared AI with Apply limits, otherwise UnmeteredScope), the default and the edited prompts per role, the functions the model calls (the manual, get_ui_state, and interact_with_ui only when interactions are allowed and there is a page) with a stub UiStateSource (also read_last_errors, and the Admin data functions: only for an administrator and only when allowed, an unknown topic refused). HelpAdminDataTest: the end of an extension's log without secrets, cut from the front, and how the tool guards the topic and the number of lines. ProblemLogTest / UiProblemsTest: the session's log of what the user was shown (newest first, 20 kept, the same message told twice is one problem, secrets removed, text cut), what is recorded from the notifications and the exceptions through a fake Vaadin session (UI.setCurrent replaces the thread's session, so the session is set after the UI), and the merge of the copy the browser kept. Its settings belong to the installation and survive between tests, so the test saves the defaults first. OsgiServiceImplTest loads, replaces and unloads extensions on a real OSGi framework with test bundles built on the fly (TestExtensionActivator), lists the invalid ones (getInvalidExtensions, not the stopped ones), including the verification reports that allow or refuse starting them; it has no Spring context; it also checks that an extension's localization bundles are registered while it is loaded, removed on unload, and that an extension can't replace a language that already has a bundle; at startup only the newest version of an extension in the folder is loaded and the older JARs are moved to old-versions; stopping and starting extensions (the .STOPPED flag survives a restart, an upload starts a stopped extension) and the extension logger (SLF4J formatting, tail reading, rotation, removal with the extension); the AIs shared by administrators (visible to all, read-only for others, only administrators share, the setting that stops users from creating AIs while keeping the existing ones) and the Usage of a user per AI (adding up, a row per user of a shared AI, who may list them, the overview of all users for administrators only).
manual ManualParserTest, ManualServiceTest The user manual of the help assistant: sections by heading with the text cleaned, the title falling back to the label, the pages loaded without pictures, finding a page by path with tolerance, links between pages pointing at existing pages, the address of each page on the published site (and that docs/ has a file for every one), the tools printing it, the search ranking the relevant page first and finding nothing without usable words.
ipc, tool IPCServiceTest, ToolServiceTest ToolServiceTest (the ToolService registry, with fake providers): a provider gets only the checked items that are still offered, nothing checked or everything gone gives no tools, a suite without items is on or off, the hint of the user replaces the default one, two suites with a function of the same name fail with both names, a provider that throws is left out, the commands of a provider are registered with it and removed with it, registering a key again keeps its place, the items as they are now, the count of what is on, and the selection as plain JSON that compact trims. The providers of IPCService: order of registration, replacing a key, separate book and story part providers, null for a provider that is gone, errors reaching the caller, removal by bundle, snapshots of the listing. (OsgiServiceImplTest checks that unloading an extension removes its providers.)
inference InferTest, InferenceSessionTest, PartialJsonTest, RoundStatsCollectorTest InferenceSessionTest: the header X-Session-ID the mock LLM receives (an opaque 32 hex hash, the same for one user and kind, other for another kind, scope, user, none outside an InferenceSession; a new part, a regeneration and the part of another book share it). InferenceService.infer against the mock LLM: whole text, schema check (SchemaCheck), answer read into a class, function calls during infer, provider failure; the schema built from @LLMSchema / @LLMField; stream passing a schema. PartialJsonTest: PartialJson reads the JSON of a streamed answer so far (unfinished strings, keys without a value, escapes, fences that are not closed yet).
revision, crud, db, loc (new) MessageRevisionServiceTest, PartHistoryRecordingTest, PartHistoryActionsTest, PartHistoryLimitTest, PartHistoryBackupTest, VersionSearchTest, PartRevisionMigrationTest, MessageRevisionCrudTest, VersionHistoryPermissionMigrationTest, RevisionLocalizationTest The version history of a part: what records a version and from which source, restore / roll back / labels / the trash, the limit and the pruning, backups with the history, Include versions in the searches, the migrations. See Version history.
prompt, domain.service.impl (new) MessagePromptTest, StoryAroundTest, PromptComparisonTest MessagePromptTest: the built prompt and the lore of a part in their own row (MessagePromptService: stored deflated, replaced, removed when empty, shared by a branch, deleted and restored with the part, kept in a backup) and the migration that moves it out of the extended content. StoryAroundTest: the story around a part as the earlier messages of the conversation of the annotate and the redraft (the budget in steps, a new part only appends). PromptComparisonTest: two built prompts compared like a prefix cache (per-message status, the first difference, the shared share).
function FunctionRegistryTest Interfaces become function schemas (the continuation is not in them), arguments are parsed, methods run on the executor and answer through FunctionContinuation (also from another thread, first answer wins), interfaces with a method that returns a value or lacks the continuation are rejected.
generation InferenceFunctionCallTest, GenerationRequestTest, LorebookActivationTest, BuiltPromptHistoryTest, ReservedTokensTest, TokenBreakdownTest, FailedGenerationTest, AILimitGenerationTest, ExtensionAIGenerationTest, ImageAttachmentGenerationTest, InferenceAttachmentsTest, MetaSummaryTest, RedraftTest, UsageGenerationTest, GenerationOptionsTest Full generations: what is sent to the model, which lore is activated and where it goes, the story history in the prompt (new part, swipe, regenerate), tokens reserved by extensions, the stored token breakdown of a prompt (sections, reserved and cut story, payload messages, null for parts without it), a failed request leaving the story unchanged, images staying with a part and never reaching the model, the attachments of an LLMChatMessage (image as a data URL or URL, WAV audio, a file; a plain string content without attachments; audio of an unsupported format rejected before any request) as the content parts of the request, meta summaries and the summary chain, redrafts (the prompt of RedraftService: the part, the instructions, the story before and after it, valid summaries standing in for the older story and outdated ones left out, the newest story kept when it does not all fit, the prompt chain book → user → default, nothing stored), the tokens counted per user (reported by the provider or estimated, also for a direct stream call, a failed request not counted); InferenceFunctionCallTest covers the model calling functions: tools in the request, results sent back, several calls and rounds, calls answering from other threads, failed and refused calls reported to the model, the round limit, cancelling while a call is pending and the tool role resolvers.
templates TemplateServiceTest, MacroRenderingTest, MacroLorebookFixtureTest, DefaultTemplatesTest Handlebars rendering and SillyTavern macros; the built-in templates.
lorebook LorebookImportExportTest Marginalia and SillyTavern lorebook import and export.
backup ManuscriptRestoreTest, ManuscriptBackupCopyTest, ManuscriptCloneTest, BackupLorebookTest, BackupAiProtocolLinkTest, BackupImagesTest, DatabaseBackupScheduleTest Book backups, restore and copy, lorebook and provider linking, the images of parts in backups (the archive made on export, own resources for restored and cloned books), scheduled database backups.
export ExporterTest, ExporterImagesTest The story exporters (TXT, Markdown, HTML, DOCX, PDF, EPUB): text and Unicode, linked contents and chapter navigation, splitting of long stories, embedded images and where they are placed among the text (all formats; PDF and Word checked by text and structure).
domain/model, ui/dialogs/manuscript PartLayoutTest, ImagesDialogTest Where the images of a part are among its text: the anchors and the cuts between blocks (never inside a list or a code block), groups, wrapping only at the sides, width limits, the old values without a position; the dialog of the images saving the position, the wrap and the width, the preview button, PartView building the pieces and the pictures.
styles (1.6.0) PartStyleTest, PartStyleServiceTest, PartStyleEditorTest, PartStyleApplierTest, PartStyleTransferTest, PrintStyleTest, ReaderStyleChooserTest The text styles: the registry with Normal and Book, the stylesheet an option set makes (nothing for an empty style, values that can not leave their declaration, the custom CSS last and with matching braces, the background on the whole element), the service (built in styles can not be changed, duplicate, delete, the default), the editor of the Settings (changes stay in memory until the settings are saved, reload is the discard), the entries of the menu of the story and the style on the element, export and import of .style.json (round trip, unknown options counted, files refused, the horror example of the manual loads), the stylesheet for paper (toPrintCss, units to points, colors to RGB), and the style chooser of the reader (the Book style until the choice, the cookie, only the styles of the reader).
export (1.6.0) ExportSettingsServiceTest, ExportSettingsEditorTest, ExportDialogStyleTest, ExporterStyleTest, ExporterTemplateTest, ExporterTargetsTest The style and the templates of each format (the own look of the format unless the user chose another, only styles for exports can be chosen, a template per format and key, a blank one is the default, an invalid one is refused), the Exports tab (stored on Save only, the defaults are not stored), the choice in the export dialog, the print style in HTML, EPUB, PDF and Word, the title page template, and the formats for other tools (front matter, AO3, SillyTavern chat, Obsidian vault).
inference (1.6.0) StreamStopTest A stop ends the wait for the server at once, both while it still reads the prompt (nothing sent) and between two chunks, with a fake server that holds the answer back.
utils AttributeMergeTest The merge of the dynamic attributes when the settings are loaded again before they are saved: the keys held by the page win, those only the fresh copy has stay.
crud, backup (extension settings, 1.6.0) ExtensionSettingsInheritanceTest, BackupExtensionSettingsTest The settings extensions add to the books: the book, then the nearest folder, then the user level, then the default of the extension; a blank value clears and a missing key leaves it; the values stay while the extension is not loaded; a failing supplier does not break the resolution; the folders of a backup come back with their extension settings and an export writes what the book inherits.
desktop (1.6.0) BundledPluginsTest The launcher of the desktop app (src/desktop/java, compiled as a test source): the first start copies every bundled plugin, a second one nothing, a plugin the administrator removed stays removed (also in a newer version), a new version is copied next to the old one.
security ApiKeyEncryptionTest API keys stored encrypted with the installation key; the app migration encrypting plain ones.
utils ClientAddressResolverTest, GeneratedTextHighlighterTest, BuildInfoTest, CommandUtilsTest, TextDiffTest, WordDiffTest, TimeSafeTextTest GeneratedTextHighlighterTest: the diff view's highlight (shared words marked, the author's text not, one mark per line, markdown line prefixes outside it, a huge rewrite). The client address: the header is ignored without trusted proxies and from any other peer, chains of proxies, CIDR ranges, no host name lookups (no Spring context).
loc LocalizationTest, BundleCompletenessTest The language of the session selects the bundle, texts a bundle lacks come from the default language, a language can't be registered twice, the default one can't be removed, UserSetting.language is saved (Spring context). Every key of the Japanese bundle is translated and its %s/%d placeholders match the English text.
ui LLMSnapshotTest, StatisticsViewTest, ActivityCalendarGridTest, WorkspaceLLMSupportTest, WorkspacePartsLLMTest, HelpAssistantWidgetTest, ResourcesPartTest, BranchSearchTest, PromptComparisonDialogTest, ViewerFoldersTest, WorkspaceStateTest, AIDialogExtensionTest, TabDockTest, LorebookPreviewTest, AutoAnnotateDialogTest, InvalidExtensionsWarningTest, LazyContextMenuTest, LazyBranchTest, MultiTagDialogTest, MassChangeModelProtocolDialogTest, MassChangeLanguageDialogTest, MassRenameDialogTest, MassRenameTest, LorebookMassEditDialogTest, LorebookLockViewTest, LorebookSubbookTokensTest, PermissionsDialogTest, LorebookSearchReplaceDialogTest, ManuscriptSearchReplaceDialogTest, ToolSelectionDialogTest The Resources tab grid: lazy paging, only the user's files, deleting the selection. The search of the branch view (ManuscriptTreePart with a stand-in for the book window): the result line, "Show in story" switching the active branch and ignoring ids of other books. WorkspaceStateTest: the autosave of the workspace - what each Restorable stores and restores (tab, filters, open book, lorebook entries, the new turn form), what is skipped when it is gone, and the state kept in UserSetting.workspaceState. AIDialogExtensionTest: the type Extension of the AI dialog with a fake factory - the provider is chosen, its form shown and applied, Test Connection uses the unsaved values, an AI of an unloaded extension is edited with a notice. TabDockTest: the sidebar TabDock - the layout it builds, detaching a tab into a sheet or a column by drag and drop, joining another sheet, the stored selection and the layout and the position of the sidebar divider kept in the attributes of the manuscript. LorebookPreviewTest: the lorebook preview of the story editor - the top level rows with and without the sub lorebooks (deep, shared and circular sub lorebooks), no lorebook, and what a refresh keeps. InvalidExtensionsWarningTest: the red bar about invalid extensions - hidden when none, names on hover for the administrator, a note without names for users, gone when fixed (with a fake OsgiService). LLMSnapshotTest: the tree the help assistant's model reads - paths, only the active branch against deep, hidden elements, a failing element, numbering of siblings, the interactions (offered now, applied in order and looked up again before each step, an unknown key, by path), the buttons that open a dialog (dialogButtons, not while a dialog is open) and getLLMProblems (an empty required field, an invalid one with its message, never a value, hidden, disabled and nested fields left out). WorkspacePartsLLMTest: what the lists of books, models and protocols (desktop and phone) and the settings tell the assistant and offer (open-dialog:, open-tab:; pressing the button is not tested here, a UI without a session can not attach the dialog). WorkspaceLLMSupportTest: the section names and the moves to open them (the workspaces themselves need the OSGi service, which the test context does not have, so there is no snapshot test of a real workspace). ToolSelectionDialogTest: the Tools window with fake providers - a section for each suite under its name, saving without a change gives the same choice, the filter of a section keeps the checked items and Check shown checks only what is shown, a suite without items is on or off and its hint is kept, and the button shows the number that is on and hides itself when no suite is installed. HelpAssistantWidgetTest: the button only while the assistant is on, the AI named, the limit warning, the stored conversation, reading and moving through a page, and merging the problems of the session with the ones the browser kept (an open dialog is added to the UI by hand: Vaadin attaches it at response time). StatisticsViewTest builds the statistics view (the tabs, the totals, the goal bars, the calendar of a book and of all books, on the desktop and on a phone) over real data; ActivityCalendarGridTest is the layout of the calendar, without a UI. They build Vaadin components without a UI session.
domain/agents, ui/dialogs/agent, ui/workspace/parts AgentServiceTest, AgentRunServiceTest, AgentRunnerTest, AgentMessageServiceTest, AgenticBooksTest, RunToolsTest, AgentRunStatusTest, AgentRunControlTest, ManuscriptLockTest, AgentSessionTest, CommandFunctionsTest, CommandFunctionsAgentTest, VirtualPathTest, VirtualFileSystemTest, AgentFileToolsTest, AgentVirtualFileSystemServiceTest, AgentDialogTest, AgentCloneAndStopDialogTest, AgentPartTest, AIGateTest, AgentApprovalTest, AgentAttentionTest, AgentContextTest, AgentCopyServiceTest, AgentDiagnosticsTest, AgentRunDiagnosticsTest, AgentHandlesTest, AgentMergeTest, AgentMergeNoLorebookTest, AgentRunPairsTest, AgentSubagentsTest, AgentSyncTest, AgentTranscriptExportTest, AgentWebhookTest, AgentWorkingCopyTest, CheckpointServiceTest, CommandScopeTest, ImageFilesTest, ImageToolsTest, LorebookLockTest, RequestSlotsTest, ZipUnpackTest, JsonMergeTest (domain/agents/jsonmerge), AgentConsoleDialogTest, AgentCheckpointBrowserTest, AgentContextViewTest, AgentCopyViewTest, AgentDiagnosticsViewTest, AgentMergeDialogTest, AgentModelsEditorTest, AgentSubAgentsViewTest, AgentWorkspaceBrowserTest, JsonMergeDialogTest, PhoneWorkspaceAgentsTest Agents (on AgentTestBase): who may have them and how many, the status table, start / pause / stop / resume, the lock of a book while a run works (every service that changes the book refuses, reading and a paused run do not); the request context of a run (the session beans resolve as the owner, the language of the run, the context handed to another thread); the commands as functions of an agent (names, the schema of the forms, the call that picks a form, bounded results, Error: messages, the destructive mode and the approval; every registered command makes a function and a call on a thread of a run reads the book of the run); the cage of the files of an agent (paths, mounts, symbolic links, limits, no real path in any message); the runner against the mock server (a reply ends the turn, calls of commands, files and run tools, finish, ask_user and a message that goes on, errors for the model, the budgets, the context cap, a pause or stop that waits for the round in the air, a model error, a transcript with an interrupted call, a trashed run); the transcript rows; the agentic flag of books (the old lists return both kinds, the regular and agentic ones split them); the dialogs and the table of the Agents tab built without a browser (ComponentTree finds buttons in a built view). DesktopWorkspace can't be built in a test (it needs OSGi), so the condition that shows the tab (AgentService.isAllowed) is tested in the service.
command CommandServiceTest, CommandDispatchTest, CommandLineTest, CommandHelpTest, BuiltInCommandTextsTest, ManuscriptReadCommandsTest, ManuscriptWriteCommandsTest, LorebookReadCommandsTest, LorebookWriteCommandsTest, GenerationCommandsTest, SearchReplaceCommandsTest, BackupCommandsTest The command registry and dispatch (overloads, parameters, async, the scope a command runs in), the line parser and the help, every built-in text, and the read and write commands of the book and the lorebooks (their results, errors and the data severity), the commands that write with the model (on the mock LLM), search and replace, the backup command. StatisticsCommandsTest checks get-book-statistics and get-statistics (the days and totals, the default range, the side, the books by day or by book with the most words first, a wrong range, side or grouping refused). The test fixture HelloWorldCommands (test/commands) is the model of a suite and of an async command.
search, manuscript, lorebook (new) TextSearchTest, ManuscriptReplaceTest, LorebookReplaceTest, ManuscriptArchiveImportTest, LorebookArchiveImportTest, LorebookEditServicesTest, PartEditServicesTest Search and replace over the parts and the entries (the regular expression, the case, the dry run, the outdated summaries), the import of a zip of .md files into a book and a lorebook, the services that edit a part or an entry.
annotation, utils (new) AnnotationServiceTest, AnnotationGenerationTest, AnnotateServiceTest, TextAlignmentTest, QuoteFinderTest, TextMarkupTest, AnnotationCleanupTest, AnnotationCrudTest Annotations: creating and listing, the passages moving with an edit (before, inside, at the edges, deleted and typed back, a typo, a replaced text found by the quote), merge, branch, delete and restore, the backup round trip, the notes marked for the prompt reaching the master template, a regenerated part; the model annotating against the mock LLM in every mode (a retry, an unreadable answer, two stages, quotes not found); the three text utilities.
crud, generation, backup, domain, ui (folders, 1.6.0) FolderCrudTest, FolderServiceTest, FolderTransferTest, FolderListTest, FolderSettingsTest, SettingsInheritanceTest, ModificationTest, FolderSettingsGenerationTest, BackupFoldersTest, BackupExportInheritanceTest, AgentFoldersTest, HelpAssistantBooksTest, FolderDialogTest, OrganiseDialogTest, OrganiseDialogFlowTest, BookPromptSettingsTest, MassChangeModelProtocolDialogTest, MassChangeLanguageDialogTest, ManuscriptPartFoldersTest, PhoneManuscriptPartFoldersTest, BookLabelsTest, StoryMassDeleteTest, TabDockIconsTest The folders, see Folders: the entity and the service rules (names, cycles, delete, the lock of an agent's book), copy and move with name clashes, the settings of the folders resolved for a book and reaching the prompt of a generation, the modification time of books and folders, the folder in a backup and the inherited settings in an export, the working copy of an agent, the books tools of the help assistant, and the windows (settings, Organise with its keys, the lists on the desktop and the phone). The mass delete of parts and the icons of the minimized tabs are here too.
cleanup CleanupServiceTest, TrashServiceTest, SoftDeleteCascadeTest, PermanentDeleteTest, AgentCleanupTest Purging soft-deleted data (also an AI with usage rows), owned data, strong and weak references; the trash: own objects only, administrator sees all, restore refused while a deleted parent is not restored with it, extended content; SoftDeleteCascadeTest: a soft delete takes the owned objects with it and a restore brings them back (the purge tests there log in as administrator); PermanentDeleteTest: deleting parts and entries for good, children moved to the parent, active leaf, blocked deletes roll back.
instruct ExtendableMethodVisitorTest, RuntimeInstrumentationTest, ExtensionVerifierTest, PublicAPISignatureTest The bytecode instrumentation of @Extendable methods and the runtime agent; the verification of extensions against the application; the @PublicAPI declarations of the @Extendable classes.

The tests added in 1.4.0 check, besides what the names say: GenerationOptionsTest (a generation with another model and protocol than the book's, the book untouched), PermissionServiceTest and PermissionsDialogTest (the permissions and their window), LazyContextMenuTest (a menu filled when it opens) and LazyBranchTest (which parts of a long story the window has loaded while it slides), TextDiffTest and WordDiffTest (the sentence and the word diff of the review), RoundStatsCollectorTest (what a streamed answer says about how it went: finish reason, cache, reasoning, llama.cpp timings), AgentCleanupTest (an agent, its runs and the working copy are deleted, restored and purged together) and ConcurrentTransactionsTest (read-write transactions take the SQLite write lock, read-only ones do not wait for them). test/TestImages makes small pictures and test/AgenticRequestAttributes the request context of a run, for the tests of the agents.

Not covered by automated tests: the Vaadin UI (apart from LLMSnapshotTest, HelpAssistantWidgetTest, ResourcesPartTest, BranchSearchTest, WorkspaceStateTest, AIDialogExtensionTest, TabDockTest, LorebookPreviewTest and InvalidExtensionsWarningTest), the bundled plugins (apart from the unit tests of google-genai and anthropic), and the desktop launcher (the CI smoke test only checks that the packaged app starts).

Test bases

classDiagram
    MarginaliaTestBase <|-- GenerationTestBase
    MarginaliaTestBase <|-- OwnedCrudContract
    OwnedCrudContract <|-- ExtendableCrudContract
    MarginaliaTestBase <|-- BackupTestBase
    GenerationTestBase <|-- LorebookActivationTest
    ExtendableCrudContract <|-- ProtocolCrudTest
    TemplateTestBase <|-- MacroRenderingTest
    class MarginaliaTestBase {
        +llm
        +createUser()
        +login()
        +createAI()
    }
    class GenerationTestBase {
        +ai
        +protocol
        +newManuscript()
        +generate()
        +onEvent()
    }
    class TemplateTestBase {
        +context
        +data
        +render()
    }

TemplateTestBase doesn't start Spring. Pick the lightest base that works:

Base Spring Use for
none no Pure units (CronScheduleTest, SillyTavernEntryConverterTest, FlywayMigrationTest).
TemplateTestBase no Anything rendered by TemplateServiceImpl.
MarginaliaTestBase yes Services, repositories, backups, cleanup.
GenerationTestBase yes Anything that needs a real generation.
OwnedCrudContract / ExtendableCrudContract yes The CRUD contract of a new entity.

MarginaliaTestBase

test/MarginaliaTestBase starts the application context from src/test/resources/META-INF/spring/test-application-config.xml. That file imports the production application-config.xml and adds TestContextPostProcessor, which

  • removes the OSGi framework (OsgiServiceImpl) and the instrumentation agent (RuntimeInstrumentationInitializer),
  • points Configuration to a new folder target/test-home/ctx-<random>/ instead of ~/.marginalia,
  • makes TestUserService the user service: it hashes passwords with one round of SHA-256 instead of PBKDF2 (65536 rounds), which was close to half of the time the tests spent, every test creates a user. authenticate works as usual; PasswordHashTest keeps the real PBKDF2 hash covered.

Everything else is the production wiring: the services, Hibernate, Flyway migrations, the session-scoped current user. The base class gives you:

Member  
userService, aiService, inferenceServices Autowired services; autowire any other bean in your test.
currentUser The session-scoped User bean - the "logged in" user of the test.
llm A mock LLM scenario for this test method.
uniqueName(prefix) prefix-1a2b3c4d, for names that must not collide with other tests.
createUser(), createUser(login, password, admin) Saves a user; an ordinary user also gets the permission CREATE_AI (as the users of an installation from before the permissions have it; AISharingTest and the help tests give HELPER the same way).
setPermission(user, type, granted), updatePermission(user, type, consumer) Create or change a Permission of a user straight in the repository (no administrator is needed, unlike PermissionService.save).
loginAs(user), login(), loginAdmin() Makes a user the current user, like a login in the UI (login() and loginAdmin() create one first). The services that only administrators can use (cleanup, database backups, user delete / unlock / clear password, extension install) need loginAdmin().
createAI() Saves an OpenAI-compatible inference provider pointing to llm.

Each test method runs in its own mock HTTP request and session (@SpringJUnitWebConfig), so session-scoped beans start empty: call login() before using a service that needs the current user.

The shared database

Spring caches the application context, and with it the database, for the whole test run. All test classes that use MarginaliaTestBase share one SQLite database. Write tests so that they don't depend on what else is in it:

  • create the data a test needs in the test, with uniqueName(...) for anything looked up by name;
  • assert on the entities you created (contains, doesNotContain), not on totals (hasSize);
  • operations that work on the whole database (cleanup, database backups) must only assert on their own data - see CleanupServiceTest.

When a test really needs an empty database or changes global state, mark it with @DirtiesContext. The next test then gets a new context with a new folder and database:

@Test
@DirtiesContext(methodMode = DirtiesContext.MethodMode.BEFORE_METHOD)   // starts on an empty database
void lastAdminIsProtected() throws Exception { ... }

@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)        // changes the shared backup schedule
class DatabaseBackupScheduleTest extends MarginaliaTestBase { ... }

The first context of a JVM costs about 20 seconds (Spring, Hibernate, Flyway; measured as the start of one test class on the first machine of the table, a context built later in a warm JVM should cost less), so use it sparingly.

The application folder

Each context gets its own folder under target/test-home/ with the database, data, backup and extension folders. They are not deleted after the run, so you can open a test database with any SQLite tool after a failure. mvn clean or deleting target/test-home removes them.

GenerationTestBase

test/GenerationTestBase runs real generations through every pipeline step (see Generation pipeline). Before each test it logs in a new user, creates an AI pointing to llm, saves a chat-completion protocol and sets the mock's fallback answer to "generated".

Member  
ai, protocol The saved provider and protocol.
newManuscript() An unsaved book with ai, protocol and minimal templates: user prompt , master template . Save it with manuscriptService.save(...).
generate(manuscript, instructions), generate(manuscript, turnInput) Generates a new part and waits up to 30 s for the end. Returns a GenerationRun.
onEvent(event, manuscript, action) Runs action when this book's generation reaches a pipeline Events value; removed after the test.

test/GenerationRun is the GenerationListener that stands in for the story editor. It records the streamed response and reasoning, errors (getErrors()) and simple errors (getSimpleErrors(), the messages the UI shows in a notification), warnings (getWarnings()), the created part (getMessage()), and the outcome (COMPLETED or CANCELLED). Questions the pipeline asks the user (askQuestion) are answered yes.

class MyGenerationTest extends GenerationTestBase {

    @Test
    void instructionsReachTheModel() throws Exception {
        Manuscript book = manuscriptService.save(newManuscript());
        llm.reply("The lighthouse was dark.");

        GenerationRun run = generate(book, "The keeper climbs the stairs.");

        assertThat(run.await(TIMEOUT)).isEqualTo(GenerationRun.Outcome.COMPLETED);
        assertThat(run.getErrors()).isEmpty();
        assertThat(run.getResponse()).isEqualTo("The lighthouse was dark.");
        assertThat(llm.lastCompletionRequest().getLastMessage().content())
                .contains("The keeper climbs the stairs.");
    }
}

To look at the pipeline state in the middle of a generation, capture it in an event listener. LorebookActivationTest reads the activated entries this way:

AtomicReference<List<LorebookEntry>> activated = new AtomicReference<>();
onEvent(Events.AFTER_PROCESS_LOREBOOK, book,
        e -> activated.set(e.getProperty(GenerationProperties.ACTIVATED_LOREBOOK_ENTRIES)));
generate(book, "...");

Listeners are global (StoryGenerationService.addEventListener); onEvent filters by book and always calls chain.next(), so a failing assertion inside the action doesn't stop the generation. Assert after generate returns, not inside the listener - an exception in the listener only ends up in the log.

CRUD contracts

Every entity that belongs to a user has a CRUD test built from crud/OwnedCrudContract. The contract contains the tests; a subclass only says how to make and change an entity:

Method  
service() The entity's OwnedService.
newEntity() A new, unsaved, valid entity (referenced entities may be saved).
assertCreated(loaded) Checks the fields set by newEntity() after a reload.
modify(entity) Changes some fields.
assertModified(loaded) Checks the changes after a reload.

The inherited tests check identity and owner on create, find by id / uuid / for user, update (creation date kept, modification date moved), soft delete (hidden from all find*ForUser and findAll* methods), hard delete, isolation between users, and that saving as another user keeps the owner.

Entities extending ExtendableEntity use crud/ExtendableCrudContract, which adds the persistence of plugin attributes (getAttributes()): nested JSON, changes and removals, unrelated updates, and saveWithoutEvent (used by restore). Put at least one @ExtendedAttribute field into newEntity() and modify(), so the inherited tests also cover the extended fields. crud/ProtocolCrudTest is a short, complete example.

When you add an entity (see Database & migrations), add its <Entity>CrudTest.

TemplateTestBase

templates/TemplateTestBase creates a TemplateServiceImpl directly, without Spring, with a deterministic context: the clock is fixed at Sunday, 15 March 2026, 14:30:45 UTC, random macros use a seeded Random, and the template data describes a scene in the middle of a story (POV Alice, characters Alice, Bob, Carol...). One data object is shared by all render(...) calls of a test, the way lorebook entries of one generation share their variables.

MacroLorebookFixtureTest renders src/test/resources/macro-test.json, a SillyTavern lorebook whose entries document their expected output. When you change macro behaviour, update the fixture and the expectations together.

The fake LLM server

test/llm/MockLLMServer is a small OpenAI-compatible server on 127.0.0.1 with a random port, built on the JDK's HttpServer. One shared server lives for the whole test JVM (MockLLMServer.shared()); each test gets its own scenario with its own base URL, http://127.0.0.1:<port>/<scenario-id>/v1. A provider created with createAI() points to the test's scenario, so tests never see each other's requests or answers.

It answers:

Endpoint  
GET /models The scenario's model list (default mock-model; change with llm.models(...)).
POST /chat/completions Streaming (server-sent events) and non-streaming chat completions.

Programming answers

Chat completions are answered in this order:

  1. queued responses - llm.enqueue(...) or llm.reply("text"), one per request;
  2. the responder function - llm.respondWith(request -> ...), if set and it returns a response;
  3. the fallback - llm.fallback(...);
  4. otherwise HTTP 500 no response programmed.

MockLLMResponse builds one answer:

MockLLMResponse.text("Hello world")                        // one content chunk
MockLLMResponse.chunks("Hel", "lo").chunkDelay(ofMillis(50))
MockLLMResponse.split(longText, 20)                        // chunks of 20 characters
MockLLMResponse.text("answer").withReasoning("thinking")   // reasoning chunks first
MockLLMResponse.text("answer").withReasoning("...").reasoningField("reasoning")  // instead of reasoning_content
MockLLMResponse.text("cut").finishReason("length")
MockLLMResponse.error(429, "slow down")                    // OpenAI-style error body
MockLLMResponse.chunks("a", "b", "c").disconnectAfter(2)   // stream ends abruptly
MockLLMResponse.text("answer").usage(120, 8)               // token counts in the last chunk (only if asked for)
MockLLMResponse.text("late").waitFor(latch)                // held until the latch is released
MockLLMResponse.toolCall("add", "{\"a\":1}")               // the model calls a function; .andToolCall(...) adds one

waitFor(latch) is the tool for "while generating" states: the request is received, the pipeline waits for the stream, and the test can stop the generation or inspect the state before releasing the latch.

Inspecting requests

Every request, including model listing, is recorded:

Method  
llm.getRequests(), llm.getCompletionRequests() All requests / chat completions, in order.
llm.lastCompletionRequest() The last chat completion; fails the test if there was none.
llm.awaitCompletionRequests(n, timeout) Waits for n completions - for asynchronous callers.
request.getMessages(), getMessages("system"), getLastMessage() The chat messages (role, content).
request.getPromptText() All messages as [role]\ncontent - handy for contains assertions.
request.getBody(), getRawBody(), getModel(), isStream(), getApiKey() The raw payload.

For code that calls an InferenceService directly (without the pipeline), test/InferenceCollector is a callback that pulls the whole stream and waits for the end (it also records the onFunctionCall reports).

Expected errors in the log

Tests of failure paths make the code log errors on purpose. To keep them out of the test output (where they look like real failures) and to assert on them, capture the logger with test/ExpectedLog:

try (ExpectedLog log = ExpectedLog.capture(ExtensionServiceImpl.class)) {   // or capture("extension.my-plugin")
    ...
    assertThat(log.errors()).anyMatch(m -> m.contains("broken"));
}

While the capture is open, the logger's events are recorded and not passed to the console; errors(), warnings() and atLeast(level) return the messages, entries() the full events with their exceptions.

Instrumentation tests

The instruct tests check the bytecode that makes @Extendable methods extensible (see Plugin development):

  • ExtendableMethodVisitorTest transforms fixture classes from instruct/fixture/ with instruct/Instrumenter - the same transformation the agent applies, without an agent - and checks that the result verifies, keeps the class schema, behaves like the original and exposes arguments and locals to decorators.

  • RuntimeInstrumentationTest installs the real agent into the test JVM and checks classes loaded both before and after the installation (fixture/agent/).

  • ExtensionVerifierTest runs the extension verification on the extensions of fixture/verify/VerifyFixtures (decorators that are valid, ask for things that don't exist, or can't be followed) against the decorated VerifyTarget. Their class files are packed into a JAR on the fly and served by a stand-in Bundle. Decorators must be anonymous classes with the context calls inside them, as in a real extension - a decorator that delegates to a helper class would not be checked.

  • PublicAPISignatureTest checks the @PublicAPI of every instrumented method of every @Extendable class of the application against the compiled classes (target/classes). It reads them with ByteBuddy's type pool and the local variable table (PublicAPISignatures), so the application classes are not loaded or initialized, and it needs the classes compiled by ajc like the application. A failure names the method and prints the annotation the method needs.

The fixtures are compiled with the same -parameters and preserveAllLocals options as the application; if you change the compiler settings in pom.xml, these tests tell you whether extensions still see names of arguments and locals.

Checklist for a new test

  • Name it <Subject>Test and put it in the package of the area it tests; Surefire runs every *Test class.

  • Extend the lightest base that works (Test bases).

  • Create your own data with unique names; don't depend on what other tests left in the shared database.

  • Don't call real models or other network services - program llm instead.

  • Assert with AssertJ, with .as("...") where the failure message would otherwise be unclear.

  • Capture expected error logs with ExpectedLog.

  • A new entity gets a CRUD contract test; a new migration is covered by FlywayMigrationTest automatically (it runs all migrations and validates the schema against the entities).

  • A bug fix comes with a test that fails without the fix, where the area is testable without the UI.

  • Run mvn test before opening a pull request - CI runs the same command.