Skip to content

Testing

Fullfinity includes a built-in test framework for testing models, actions, and business logic. Tests run against a real database with the full ORM and environment available.

Tests are run via the CLI:

Terminal window
# Run all tests
fullfinity-server --config config.yaml -db testdb test
# Run tests for a specific module
fullfinity-server --config config.yaml -db testdb test --module invoicing
# Run with verbose output
fullfinity-server --config config.yaml -db testdb test -v
# Run tests matching a pattern
fullfinity-server --config config.yaml -db testdb test -k test_create
OptionDescription
--module, -mOnly run tests for the specified module
--verbose, -vShow detailed output for each test
-k PATTERNOnly run tests whose names contain the pattern

Create test files in your module’s tests/ directory with the test_ prefix:

modules/mymodule/
├── models/
│ └── my_model.py
├── tests/ # Tests directory
│ ├── test_my_model.py # Test files start with test_
│ └── test_actions.py
└── manifest.json

Test classes inherit from TestCase and contain async test methods:

from fullfinity.engine.testing import TestCase
class TestMyModel(TestCase):
"""Test MyModel CRUD operations."""
async def test_create_record(self):
"""Test creating a record."""
MyModel = get_model("MyModel")
record = await MyModel.create(
name="Test Record",
value=100,
)
self.assertIsNotNone(record.id)
self.assertEqual(record.name, "Test Record")
self.assertEqual(record.value, 100)
async def test_update_record(self):
"""Test updating a record."""
MyModel = get_model("MyModel")
record = await MyModel.create(name="Original")
await record.update(name="Updated")
# Reload from database to verify
reloaded = await MyModel.filter(id=record.id).first()
self.assertEqual(reloaded.name, "Updated")
async def test_delete_record(self):
"""Test deleting a record."""
MyModel = get_model("MyModel")
record = await MyModel.create(name="To Delete")
record_id = record.id
await record.delete()
# Verify deletion
deleted = await MyModel.filter(id=record_id).first()
self.assertIsNone(deleted)

Use get_model("ModelName") to get model classes:

async def test_with_multiple_models(self):
Contact = get_model("Contact")
Order = get_model("Order")
Product = get_model("Product")
# Create related records
contact = await Contact.create(name="Customer")
product = await Product.create(name="Widget", price=9.99)
order = await Order.create(
contact=contact,
lines=[["C", None, {"product": product.id, "quantity": 2}]]
)
self.assertEqual(order.contact.id, contact.id)
MethodDescription
assertEqual(a, b, msg=None)Assert a == b
assertNotEqual(a, b, msg=None)Assert a != b
assertTrue(x, msg=None)Assert x is True
assertFalse(x, msg=None)Assert x is False
assertIsNone(x, msg=None)Assert x is None
assertIsNotNone(x, msg=None)Assert x is not None
MethodDescription
assertIn(a, b, msg=None)Assert a in b
assertNotIn(a, b, msg=None)Assert a not in b
assertEmpty(obj, msg=None)Assert len(obj) == 0
assertNotEmpty(obj, msg=None)Assert len(obj) > 0
MethodDescription
assertGreater(a, b, msg=None)Assert a > b
assertGreaterEqual(a, b, msg=None)Assert a >= b
assertLess(a, b, msg=None)Assert a < b
assertLessEqual(a, b, msg=None)Assert a <= b
assertAlmostEqual(a, b, places=7, msg=None)Assert a ≈ b within decimal places
MethodDescription
assertIsInstance(obj, cls, msg=None)Assert isinstance(obj, cls)

Use assertRaises as a context manager:

async def test_validation_error(self):
MyModel = get_model("MyModel")
with self.assertRaises(UserError):
await MyModel.create(value=-1) # Should raise UserError
async def test_exception_message(self):
with self.assertRaises(UserError) as ctx:
await self.do_invalid_operation()
# Access the exception
self.assertIn("invalid", str(ctx.exception))

Assert that records match expected field values:

async def test_record_values(self):
Order = get_model("Order")
orders = await Order.filter(state="Posted").all()
self.assertRecordValues(orders, [
{"name": "SO-001", "state": "Posted", "total": 100.0},
{"name": "SO-002", "state": "Posted", "total": 250.0},
])

Methods called before/after each test:

class TestWithSetup(TestCase):
async def setUp(self):
"""Called before each test method."""
# Create test data
Contact = get_model("Contact")
self.test_contact = await Contact.create(name="Test Contact")
async def tearDown(self):
"""Called after each test method."""
# Clean up (optional - tests run in isolation)
if self.test_contact:
await self.test_contact.delete()
async def test_using_fixture(self):
"""Test that uses the setUp fixture."""
self.assertIsNotNone(self.test_contact.id)

Class-level setup called once for all tests:

class TestWithClassSetup(TestCase):
@classmethod
async def setUpClass(cls):
"""Called once before all tests in the class."""
# Create shared fixtures
Category = get_model("Category")
cls.category = await Category.create(name="Shared Category")
@classmethod
async def tearDownClass(cls):
"""Called once after all tests in the class."""
if cls.category:
await cls.category.delete()
async def test_using_shared_fixture(self):
"""All tests can use cls.category."""
self.assertIsNotNone(self.category.id)

Register cleanup functions to run after a test:

async def test_with_cleanup(self):
record = await self.create_temporary_record()
# Register cleanup - runs even if test fails
self.addCleanup(record.delete)
# Test logic...
self.assertIsNotNone(record.id)
class TestSkipping(TestCase):
@TestCase.skip("Feature not implemented yet")
async def test_future_feature(self):
"""This test will be skipped."""
pass
@TestCase.skipIf(not HAS_FEATURE, "Requires feature X")
async def test_optional_feature(self):
"""Skipped if condition is True."""
pass

:::caution Important Tests run against a real database and test data persists. Use a dedicated test database to avoid polluting production data. :::

Create a separate database for testing:

Terminal window
# Create a test database
createdb testdb
# Initialize with core module
fullfinity-server --config config.yaml -db testdb --init core
# Run tests
fullfinity-server --config config.yaml -db testdb test

Test files in tests/ directories are not auto-loaded by the module system. They are only discovered and executed when you run the test command.

Each test should be independent and not rely on state from other tests:

# Good - creates its own data
async def test_order_total(self):
order = await self.create_test_order()
self.assertEqual(order.total, 100.0)
# Bad - relies on data from another test
async def test_order_total(self):
order = await Order.filter(name="SO-001").first() # May not exist
self.assertEqual(order.total, 100.0)

Use descriptive test names that explain what’s being tested:

# Good
async def test_order_total_includes_tax(self):
...
async def test_invoice_rejects_negative_amounts(self):
...
# Bad
async def test_order(self):
...
async def test_1(self):
...

Each test should verify one specific behavior:

# Good - focused tests
async def test_order_creation(self):
order = await Order.create(...)
self.assertIsNotNone(order.id)
async def test_order_line_subtotal(self):
line = await OrderLine.create(...)
self.assertEqual(line.subtotal, 50.0)
# Bad - testing too much
async def test_order_everything(self):
order = await Order.create(...)
self.assertIsNotNone(order.id)
self.assertEqual(order.state, "Draft")
await order.action_confirm()
self.assertEqual(order.state, "Confirmed")
# ... 50 more assertions

Create helper methods for commonly needed test data:

class TestOrder(TestCase):
async def create_test_product(self, **kwargs):
"""Helper to create a product for testing."""
Product = get_model("Product")
defaults = {"name": "Test Product", "price": 10.0}
defaults.update(kwargs)
return await Product.create(**defaults)
async def create_test_order(self, **kwargs):
"""Helper to create an order for testing."""
contact = await self.create_test_contact()
product = await self.create_test_product()
Order = get_model("Order")
return await Order.create(
contact=contact,
lines=[["C", None, {"product": product.id, "quantity": 1}]],
**kwargs
)
async def test_order_workflow(self):
order = await self.create_test_order()
# Test with clean, isolated data
"""
Tests for the Invoice model.
Run with:
fullfinity-server --config config.yaml -db testdb test --module invoicing
"""
from fullfinity.engine.testing import TestCase
class TestInvoice(TestCase):
"""Test Invoice model operations."""
async def create_test_contact(self):
"""Create a test customer."""
Contact = get_model("Contact")
return await Contact.create(
name="Test Customer",
is_customer=True,
)
async def create_test_invoice(self, **kwargs):
"""Create a test invoice with defaults."""
Invoice = get_model("FinancialDocument")
contact = kwargs.pop("contact", None) or await self.create_test_contact()
return await Invoice.create(
type="Customer Invoice",
contact=contact,
**kwargs
)
async def test_create_draft_invoice(self):
"""Test creating a draft invoice."""
invoice = await self.create_test_invoice()
self.assertIsNotNone(invoice.id)
self.assertEqual(invoice.state, "Draft")
self.assertEqual(invoice.type, "Customer Invoice")
async def test_invoice_requires_contact(self):
"""Test that invoice requires a contact."""
Invoice = get_model("FinancialDocument")
with self.assertRaises(UserError):
await Invoice.create(type="Customer Invoice")
async def test_post_invoice(self):
"""Test posting an invoice."""
invoice = await self.create_test_invoice()
self.assertEqual(invoice.state, "Draft")
await invoice.action_post()
self.assertEqual(invoice.state, "Posted")
self.assertIsNotNone(invoice.name) # Should have sequence number
async def test_invoice_total_computation(self):
"""Test that invoice total is computed correctly."""
Product = get_model("Product")
product = await Product.create(name="Widget", list_price=25.0)
invoice = await self.create_test_invoice()
await invoice.update(
lines=[["C", None, {
"product": product.id,
"quantity": 4,
"unit_price": 25.0,
}]]
)
self.assertEqual(invoice.amount_untaxed, 100.0)
class TestInvoicePayment(TestCase):
"""Test invoice payment operations."""
@TestCase.skip("Payment module integration pending")
async def test_register_payment(self):
"""Test registering a payment against an invoice."""
pass