Agent Guidelines for tests

June 4, 2026 · View on GitHub

Test infrastructure for VNote using the QtTest framework. All tests live under tests/ and are built via CMake's add_qt_test() helper. Tests exercise services, models, controllers, and utilities against the vxcore C backend in isolated test mode.

Test Structure

tests/
├── CMakeLists.txt          # Root test config with add_qt_test() helper
├── helpers/
│   ├── CMakeLists.txt
│   ├── test_helper.h       # Common includes
│   └── temp_dir_fixture.h  # QTemporaryDir wrapper (+ copyFrom() for fixtures)
├── data/                   # On-disk test fixtures (see "Test Data Fixtures" below)
│   └── vnote3_notebooks/
│       └── database_notebook/  # Real VNote3 notebook with 17 files + subfolder
├── core/
│   ├── CMakeLists.txt
│   ├── test_error.cpp
│   ├── test_exception.cpp
│   ├── test_configservice.cpp
│   ├── test_notebookservice.cpp
│   ├── test_bufferservice.cpp   # BufferCoreService tests
│   ├── test_buffer.cpp          # Buffer2 + BufferService integration tests (29 cases)
│   └── test_vnote3migrationservice.cpp  # VNote3 migration tests (46 cases)
└── utils/
    ├── CMakeLists.txt
    ├── test_pathutils.cpp
    └── test_htmlutils.cpp

CRITICAL: Test Mode for vxcore

Always enable test mode BEFORE creating vxcore context in tests:

void TestMyService::initTestCase() {
  // CRITICAL: Must call BEFORE vxcore_context_create()
  // Prevents tests from corrupting real user data
  vxcore_set_test_mode(1);

  m_ctx = vxcore_context_create();
  // ...
}

Why this matters:

  • Without test mode, vxcore uses real AppData/Local paths
  • Tests will corrupt actual user configuration
  • Test mode redirects to isolated temp directories

Writing Service Tests

// tests/core/test_myservice.cpp
#include <QtTest>

#include <vxcore/vxcore.h>

#include <core/myservice.h>

namespace tests {

class TestMyService : public QObject {
  Q_OBJECT

private slots:
  void initTestCase();
  void cleanupTestCase();
  void testBasicOperation();

private:
  vxcore_context *m_ctx = nullptr;
};

void TestMyService::initTestCase() {
  vxcore_set_test_mode(1);  // CRITICAL!
  m_ctx = vxcore_context_create();
}

void TestMyService::cleanupTestCase() {
  vxcore_context_destroy(m_ctx);
  m_ctx = nullptr;
}

void TestMyService::testBasicOperation() {
  vnotex::MyService service(m_ctx);
  QVERIFY(!service.doSomething().isEmpty());
}

} // namespace tests

QTEST_GUILESS_MAIN(tests::TestMyService)
#include "test_myservice.moc"

Enable Tests

Uncomment in root CMakeLists.txt:

add_subdirectory(tests)

Build Tests

cmake --build build --config Release --target test_error test_exception test_pathutils test_htmlutils

Test Libraries

LibraryPurposeLinks
core_servicesService layer (ConfigCoreService, NotebookCoreService, etc.)Qt6::Core, Qt6::Gui, vxcore
core_configsConfig classes (ConfigMgr2, MainConfig, SessionConfig, etc.)core_services, VTextEdit

Run Tests

Windows (Qt DLLs must be in PATH):

$env:PATH = "C:/Qt/6.9.3/msvc2022_64/bin;" + $env:PATH
./build/tests/core/test_error.exe
./build/tests/core/test_configservice.exe
./build/tests/core/test_notebookservice.exe

Using CTest (requires Qt in system PATH):

ctest --test-dir build                    # Run all tests
ctest --test-dir build -R test_error      # Run single test (pattern match)
ctest --test-dir build --output-on-failure  # Show output on failure

VTextEdit.dll runtime copy

Tests that link core_services (transitively or directly) load VTextEdit.dll at runtime. The build copies the DLL next to each subdirectory's test exes via a POST_BUILD step anchored on one test target per subdir (the canonical reference is tests/utils/CMakeLists.txt:78-83):

# Copy VTextEdit DLL next to test executable so CTest can find it at runtime.
add_custom_command(TARGET test_clipboard_image POST_BUILD
  COMMAND ${CMAKE_COMMAND} -E copy_if_different
    $<TARGET_FILE:VTextEdit>
    $<TARGET_FILE_DIR:test_clipboard_image>
)

When adding tests to a NEW subdirectory under tests/, that subdir's CMakeLists.txt MUST include this POST_BUILD block anchored on any one test target. Without it, ctest reports the test as failed with exit code 0xc0000135 (Windows "DLL not found") and the test binary never reaches its main(). One copy per subdir is enough; copy_if_different makes incremental rebuilds free.

Adding New Tests

Use the add_qt_test() helper function in CMakeLists.txt:

# tests/module/CMakeLists.txt
add_qt_test(test_myclass
  SOURCES
    test_myclass.cpp
    ${CMAKE_SOURCE_DIR}/src/module/myclass.cpp
  LINKS
    Qt6::Gui  # Optional: extra Qt modules
  GUILESS     # Optional: use QCoreApplication instead of QApplication
)

Qt Test Macros

MacroPurpose
QTEST_MAIN(Class)Creates main(), runs tests with QApplication
QTEST_GUILESS_MAIN(Class)Headless test runner (preferred)
QVERIFY(condition)Assert condition is true
QCOMPARE(actual, expected)Assert equality
QFETCH(type, name)Fetch data-driven test value
QTest::addColumn<T>("name")Declare data column
QTest::newRow("name")Add data row
QSKIP("reason")Skip test
QTest::ignoreMessage(type, msg)Suppress expected qDebug/qWarning/qCritical

Test Data Fixtures

Tests that need realistic directory structures (e.g., legacy notebook migration) use on-disk fixtures under tests/data/ instead of hardcoding JSON and file creation in C++.

Pattern

  1. Create fixture directory under tests/data/ with real files on disk
  2. Locate at runtime using QFINDTESTDATA (works out of the box with Qt6+CMake)
  3. Copy to temp dir using TempDirFixture::copyFrom() for test isolation

Example

// In your test class
QString findFixture(const QString &p_relPath) {
  // Path is relative to the test source file
  QString path = QFINDTESTDATA(p_relPath);
  QVERIFY2_RETURN(!path.isEmpty(),
    qPrintable(QStringLiteral("Fixture not found: %1").arg(p_relPath)),
    QString());
  return path;
}

void TestMyService::testWithFixture() {
  // Locate fixture (relative to this .cpp file's directory)
  QString fixturePath = findFixture(
    QStringLiteral("../data/vnote3_notebooks/database_notebook"));
  QVERIFY2(!fixturePath.isEmpty(), "Fixture not found");

  // Copy to isolated temp dir
  TempDirFixture workDir;
  QString sourceDir = workDir.copyFrom(fixturePath, QStringLiteral("source"));
  QVERIFY2(!sourceDir.isEmpty(), "Failed to copy fixture");

  // Test against the copy
  // ...
}

Adding New Fixtures

  1. Create directory under tests/data/ with descriptive name
  2. Add all files the test needs (stubs are fine — content doesn't have to be real)
  3. For Chinese/Unicode filenames, the files must exist on disk with those names
  4. No CMake changes needed — QFINDTESTDATA resolves paths automatically via QT_TESTCASE_SOURCEDIR

Existing Fixtures

FixturePathDescription
database_notebooktests/data/vnote3_notebooks/database_notebook/VNote3 notebook with 17 files (Chinese + ASCII), 1 subfolder, 3 attachment dirs

Real Keychain Usage

All new or modified tests that instantiate the real SyncCredentialsStore (not a MockCredentialsStore / in-memory fake) MUST register a tests::KeychainGuard to track and clean up written PAT entries.

Why

Without deterministic cleanup, tests leak PAT entries into the host OS keychain. Fixed-ID tests overwrite themselves harmlessly, but UUID-based writes accumulate forever. QtKeychain has no enumerate API (see src/core/services/synccredentialsstore.cpp:71), so cleanup cannot be done by sweeping the keychain at startup; it must be driven by tracking each write.

How

#include "../helpers/keychain_guard.h"

// Class-level member:
tests::KeychainGuard *m_keychainGuard = nullptr;

// In initTestCase(), after registering SyncCredentialsStore:
auto *credStore = m_services.get<vnotex::SyncCredentialsStore>();
m_keychainGuard = new tests::KeychainGuard(credStore, this);

// In cleanupTestCase(), BEFORE destroying the vxcore context:
if (m_keychainGuard) {
  m_keychainGuard->cleanup();
  delete m_keychainGuard;
  m_keychainGuard = nullptr;
}

// For indirect writes (e.g. via SyncService::enableSyncForNotebook),
// also call track() defensively in case the credentialsStored signal races:
m_keychainGuard->track(notebookId);

Ordering

cleanup() MUST run before the vxcore context is destroyed. The guard talks to SyncCredentialsStore, which depends on ServiceLocator and the live vxcore context; tearing the context down first leaves the guard with dangling references.

Cross-reference

For the prod-side cleanup contract (5 sites in SyncService), see ../src/core/services/AGENTS.md § Credential Cleanup Invariants.

Mandatory KeychainGuard rollout (post-fix-failing-tests plan)

All sync tests that instantiate SyncCredentialsStore (whether through ServiceLocator or by local construction) MUST register a tests::KeychainGuard. This is non-negotiable: missing guards cause cumulative notebook_sync_pat_<uuid> accumulation in the Windows Credential Manager, eventually tripping Win32 error 8 ("Not enough storage") which cascades every sync test into failure on subsequent runs.

As of 2026-06-05, the following tests are compliant:

  • test_synccredentialsstore (canonical reference)
  • test_sync_signal_auto_baseline, test_sync_signal_baseline
  • test_sync_hooks, test_sync_ops, test_sync_auto_route, test_sync_close_block
  • test_bootstrap_and_persist
  • test_syncservice, test_syncservice_lifecycle

(10 tests total.) New sync tests MUST follow the template in the "Real Keychain Usage → How" section above. Tests that do NOT touch the keychain (test_eventbridge_sync, test_notebookcoreservice_sync) are exempt.

Operator note (Windows): the keychain entry surfaces in cmdkey /list as LegacyGeneric:target=notebook_sync_pat_<uuid> (CRED_TYPE_GENERIC). KeychainGuard::cleanup() delegates to SyncCredentialsStore::deleteCredentials(), which handles the full target name internally; operators do NOT need to manage the LegacyGeneric:target= prefix manually when invoking the guard.

Current Test Coverage

TestClassTest Cases
test_errorError, ErrorCode23
test_exceptionException20
test_pathutilsPathUtils68
test_htmlutilsHtmlUtils31
test_fileutils2FileUtils215
test_configserviceConfigCoreService10
test_notebookserviceNotebookCoreService33
test_bufferserviceBufferCoreService-
test_bufferBuffer2 + BufferService29
test_vnote3migrationserviceVNote3MigrationService46
test_searchserviceSearchCoreService-
test_servicelocatorServiceLocator-
test_configmgr2ConfigMgr27
test_hookmanagerHookManager24
test_hookintegrationHook integration10
Total267+

Debugging Tips

Windows exit code 0xC0000409 is NOT a stack overrun

0xC0000409 (STATUS_STACK_BUFFER_OVERRUN) is misleading. On Windows, Qt's qFatal() calls __fastfail(FAST_FAIL_FATAL_APP_EXIT), which the OS reports as 0xC0000409. The actual cause is almost always a qFatal() deep inside Qt, e.g., QFontDatabase: Must construct a QGuiApplication before accessing QFontDatabase raised when a QTEST_GUILESS_MAIN test transitively touches font-database code.

When a test exits with this code, re-run it with file-based output capture to surface the real Qt diagnostic:

$env:PATH = "C:/Qt/6.9.3/msvc2022_64/bin;" + $env:PATH
& "build/.../tests/<category>/test_name.exe" -o "output.txt,txt"
Get-Content output.txt | Select-String "QFATAL|FAIL"

The QFATAL : ... message line names the real cause. Do NOT chase the test for recursion, oversized stack allocations, or fixture loops; that is a false trail. Fix the underlying Qt precondition (switch to QTEST_MAIN, guard the GUI-dependent code path, or short-circuit before the offending Qt subsystem is touched).