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.
Running Tests
Section titled “Running Tests”Tests are run via the CLI:
# Run all testsfullfinity-server --config config.yaml -db testdb test
# Run tests for a specific modulefullfinity-server --config config.yaml -db testdb test --module invoicing
# Run with verbose outputfullfinity-server --config config.yaml -db testdb test -v
# Run tests matching a patternfullfinity-server --config config.yaml -db testdb test -k test_createCLI Options
Section titled “CLI Options”| Option | Description |
|---|---|
--module, -m | Only run tests for the specified module |
--verbose, -v | Show detailed output for each test |
-k PATTERN | Only run tests whose names contain the pattern |
Writing Tests
Section titled “Writing Tests”Test File Location
Section titled “Test File Location”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.jsonBasic Test Class
Section titled “Basic Test Class”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)Accessing Models
Section titled “Accessing Models”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)Assertions
Section titled “Assertions”Basic Assertions
Section titled “Basic Assertions”| Method | Description |
|---|---|
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 |
Collection Assertions
Section titled “Collection Assertions”| Method | Description |
|---|---|
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 |
Comparison Assertions
Section titled “Comparison Assertions”| Method | Description |
|---|---|
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 |
Type Assertions
Section titled “Type Assertions”| Method | Description |
|---|---|
assertIsInstance(obj, cls, msg=None) | Assert isinstance(obj, cls) |
Exception Testing
Section titled “Exception Testing”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))Record Value Assertions
Section titled “Record Value Assertions”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}, ])Test Lifecycle
Section titled “Test Lifecycle”setUp and tearDown
Section titled “setUp and tearDown”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)setUpClass and tearDownClass
Section titled “setUpClass and tearDownClass”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)Cleanup Callbacks
Section titled “Cleanup Callbacks”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)Skipping Tests
Section titled “Skipping Tests”Skip Decorators
Section titled “Skip Decorators”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.""" passTest Database
Section titled “Test Database”:::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:
# Create a test databasecreatedb testdb
# Initialize with core modulefullfinity-server --config config.yaml -db testdb --init core
# Run testsfullfinity-server --config config.yaml -db testdb testTest files in tests/ directories are not auto-loaded by the module system. They are only discovered and executed when you run the test command.
Best Practices
Section titled “Best Practices”1. Test Isolation
Section titled “1. Test Isolation”Each test should be independent and not rely on state from other tests:
# Good - creates its own dataasync def test_order_total(self): order = await self.create_test_order() self.assertEqual(order.total, 100.0)
# Bad - relies on data from another testasync def test_order_total(self): order = await Order.filter(name="SO-001").first() # May not exist self.assertEqual(order.total, 100.0)2. Descriptive Names
Section titled “2. Descriptive Names”Use descriptive test names that explain what’s being tested:
# Goodasync def test_order_total_includes_tax(self): ...
async def test_invoice_rejects_negative_amounts(self): ...
# Badasync def test_order(self): ...
async def test_1(self): ...3. Test One Thing
Section titled “3. Test One Thing”Each test should verify one specific behavior:
# Good - focused testsasync 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 muchasync 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 assertions4. Use Fixtures for Repeated Data
Section titled “4. Use Fixtures for Repeated Data”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 dataExample: Complete Test File
Section titled “Example: Complete Test File”"""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