Moqui Service Testing Guide
How to test that a service has thrown a certain error when testing services in the Moqui framework.
Key Concept: Moqui Error Handling Philosophy
In Moqui, services typically don’t throw exceptions for business logic errors. Instead, they add error messages to the MessageFacade (ec.message). This is a fundamental difference from traditional exception-based error handling.
Why This Matters
Allows collecting multiple errors in a single service call
Errors can be field-specific (validation errors)
Easier to display errors in UI context
Services can continue executing to gather all validation issues
Quick Answer
Instead of using thrown(), check ec.message.hasError():
// ❌ DON'T do this (won't work for most Moqui services)
then:
thrown(ServiceException)
// ✅ DO this instead
then:
ec.message.hasError()
ec.message.errorsString.contains("expected error text")
Testing Methods
Method 1: Basic Error Detection (Most Common)
Test that a service adds an error message:
import spock.lang.*
import org.moqui.context.ExecutionContext
import org.moqui.Moqui
class MyServiceTests extends Specification {
@Shared
ExecutionContext ec
def setupSpec() {
ec = Moqui.getExecutionContext()
}
def cleanupSpec() {
ec.destroy()
}
def "service should add error for invalid input"() {
when:
ec.service.sync()
.name("growerp.100.MyService.create#Something")
.parameters([invalidParam: "bad value"])
.call()
then:
ec.message.hasError()
cleanup:
ec.message.clearErrors()
}
}
Method 2: Test Specific Error Message
Verify the exact error message text:
def "service should return specific error message"() {
when:
ec.service.sync()
.name("myService")
.parameters([email: null]) // Missing required field
.call()
then:
ec.message.hasError()
ec.message.errors.contains("Email is required")
// Alternative: check if error contains substring
ec.message.errorsString.contains("required")
cleanup:
ec.message.clearErrors()
}
Method 3: Test Multiple Errors
Check that multiple validation errors are added:
def "service should add multiple validation errors"() {
when:
ec.service.sync()
.name("create#User")
.parameters([
email: "invalid-email",
password: "123", // Too short
age: -5 // Invalid
])
.call()
then:
ec.message.hasError()
ec.message.errors.size() >= 3
ec.message.errorsString.contains("email")
ec.message.errorsString.contains("password")
ec.message.errorsString.contains("age")
cleanup:
ec.message.clearErrors()
}
Method 4: Test Field-Specific Validation Errors
Test validation errors that are tied to specific fields:
def "service should add validation error for invalid email field"() {
when:
ec.service.sync()
.name("growerp.100.PartyServices100.register#User")
.parameters([
email: "not-an-email",
firstName: "Test"
])
.call()
then:
ec.message.hasError()
ec.message.validationErrors.size() > 0
// Check specific field
def emailError = ec.message.validationErrors.find { it.field == "email" }
emailError != null
emailError.message.contains("invalid") || emailError.message.contains("format")
cleanup:
ec.message.clearErrors()
}
Method 5: Test Exception Throwing (Rare Cases)
For the rare cases where a service actually throws an exception:
import org.moqui.service.ServiceException
def "service should throw exception for critical system error"() {
when:
ec.service.sync()
.name("service.that.throws")
.parameters([param: "value"])
.call()
then:
thrown(ServiceException)
}
def "service should throw specific exception with message"() {
when:
ec.service.sync()
.name("service.that.throws")
.call()
then:
ServiceException ex = thrown()
ex.message.contains("Database connection failed")
}
Method 6: Comprehensive Error Testing
Test both error conditions and success conditions:
def "service should handle both valid and invalid inputs"() {
when: "invalid input provided"
def invalidResult = ec.service.sync()
.name("process#Order")
.parameters([orderId: "INVALID"])
.call()
then: "errors are added"
ec.message.hasError()
ec.message.errorsString.contains("Order not found")
invalidResult.success == false
when: "valid input provided"
ec.message.clearErrors()
def validResult = ec.service.sync()
.name("process#Order")
.parameters([orderId: "VALID_ORDER"])
.call()
then: "no errors"
!ec.message.hasError()
validResult.success == true
}
MessageFacade API Reference
Checking for Errors
// Check if any error exists
boolean hasError = ec.message.hasError()
// Get count of errors
int errorCount = ec.message.errors.size()
// Check specific error exists
boolean hasSpecific = ec.message.errors.contains("Exact error message")
// Check error substring
boolean hasSubstring = ec.message.errorsString.contains("partial error")
Getting Error Details
// Get all error messages as List
List<String> errorList = ec.message.errors
// Get all errors as concatenated String
String allErrors = ec.message.errorsString
// Get validation errors (field-specific)
List<ValidationError> validationErrors = ec.message.validationErrors
// Get specific validation error
def fieldError = ec.message.validationErrors.find { it.field == "email" }
Clearing Errors
// Clear only errors
ec.message.clearErrors()
// Clear all messages (errors, warnings, info)
ec.message.clearAll()
ValidationError Properties
ValidationError ve = ec.message.validationErrors[0]
String form = ve.form // Form name (if applicable)
String field = ve.field // Field name
String service = ve.serviceName // Service that added error
String message = ve.message // Error message
Complete Test Class Example
import spock.lang.*
import org.moqui.context.ExecutionContext
import org.moqui.Moqui
class LandingPageServiceTests extends Specification {
@Shared
ExecutionContext ec
def setupSpec() {
// Initialize Moqui framework
ec = Moqui.getExecutionContext()
ec.user.loginUser("john.doe", "moqui")
}
def cleanupSpec() {
// Cleanup framework
ec.destroy()
}
def setup() {
// Before each test
ec.artifactExecution.disableAuthz() // Disable authorization
ec.message.clearAll() // Clear all messages
}
def cleanup() {
// After each test
ec.artifactExecution.enableAuthz() // Re-enable authorization
ec.message.clearAll() // Clear messages
}
def "test landing page generation without API key"() {
given:
ec.user.setPreference("GEMINI_API_KEY", null)
when:
ec.service.sync()
.name("growerp.100.LandingPageServices100.generate#LandingPageWithAI")
.parameters([
businessDescription: "Test business",
targetAudience: "Test audience"
])
.call()
then:
ec.message.hasError()
ec.message.errorsString.contains("Gemini API key not found")
cleanup:
ec.user.setPreference("GEMINI_API_KEY", "test-key")
}
def "test landing page creation with missing required field"() {
when:
ec.service.sync()
.name("create#growerp.landing.LandingPage")
.parameters([
title: "Test Page" // landingPageId is required but missing
])
.call()
then:
ec.message.hasError()
ec.message.validationErrors.size() > 0
}
def "test successful landing page creation has no errors"() {
when:
def result = ec.service.sync()
.name("create#growerp.landing.LandingPage")
.parameters([
landingPageId: "TEST123",
title: "Test Page",
ownerPartyId: "DEMO_OWNER"
])
.call()
then:
!ec.message.hasError()
result.landingPageId == "TEST123"
cleanup:
ec.service.sync()
.name("delete#growerp.landing.LandingPage")
.parameters([landingPageId: "TEST123"])
.call()
}
def "test service with multiple validation errors"() {
when:
ec.service.sync()
.name("growerp.100.PartyServices100.register#User")
.parameters([
email: "invalid",
firstName: "", // Empty
password: "123" // Too short
])
.call()
then:
ec.message.hasError()
ec.message.errors.size() >= 2
ec.message.validationErrors.any { it.field == "email" }
ec.message.errorsString.contains("email")
}
}
Testing Best Practices
1. Always Clear Messages Between Tests
def setup() {
ec.message.clearAll() // Before each test
}
def cleanup() {
ec.message.clearAll() // After each test
}
Why? Messages persist in the ExecutionContext and can leak between tests.
2. Disable Authorization in Tests
def setup() {
ec.artifactExecution.disableAuthz()
}
def cleanup() {
ec.artifactExecution.enableAuthz()
}
Why? Tests should focus on business logic, not authorization.
3. Test Both Success and Error Cases
def "test service validation"() {
when: "invalid input"
// ... test error case
when: "valid input"
ec.message.clearErrors()
// ... test success case
}
Why? Ensures service works correctly in both scenarios.
4. Use Specific Error Message Assertions
// ❌ Too generic
then:
ec.message.hasError()
// ✅ More specific
then:
ec.message.errorsString.contains("Email is required")
ec.message.validationErrors.any { it.field == "email" }
Why? Catches more bugs and makes tests self-documenting.
5. Clean Up Test Data
cleanup:
// Delete any test data created
ec.service.sync()
.name("delete#TestEntity")
.parameters([testId: "TEST123"])
.call()
Why? Prevents test data accumulation and ensures clean state.
6. Use @Shared for ExecutionContext
@Shared
ExecutionContext ec
Why? Reuses the same ExecutionContext across all tests for better performance.
Common Testing Patterns
Pattern 1: Test Required Field Validation
def "test required field validation"() {
when:
ec.service.sync()
.name("create#Entity")
.parameters([
// requiredField is missing
optionalField: "value"
])
.call()
then:
ec.message.hasError()
ec.message.validationErrors.any {
it.field == "requiredField" && it.message.contains("required")
}
}
Pattern 2: Test Business Rule Validation
def "test age must be 18 or older"() {
when:
ec.service.sync()
.name("register#User")
.parameters([age: 17])
.call()
then:
ec.message.hasError()
ec.message.errorsString.contains("18 years") ||
ec.message.errorsString.contains("age")
}
Pattern 3: Test Service Error vs Exception
def "test service handles database errors gracefully"() {
when:
// Simulate database error (duplicate key, etc.)
ec.service.sync()
.name("create#User")
.parameters([userId: "EXISTING_ID"])
.call()
then:
// Check if service added error instead of throwing
ec.message.hasError()
ec.message.errorsString.contains("already exists") ||
ec.message.errorsString.contains("duplicate")
}
Pattern 4: Test Error Recovery
def "test service can recover after error"() {
when: "first call fails"
ec.service.sync().name("myService")
.parameters([invalid: true])
.call()
then:
ec.message.hasError()
when: "clear errors and try again with valid data"
ec.message.clearErrors()
def result = ec.service.sync().name("myService")
.parameters([valid: true])
.call()
then:
!ec.message.hasError()
result.success == true
}
Troubleshooting
Problem: Errors Persist Between Tests
Symptom:
// First test adds error
// Second test fails because hasError() is still true
Solution:
def cleanup() {
ec.message.clearAll() // or clearErrors()
}
Problem: Service Doesn’t Add Expected Error
Check:
- Service might be throwing exception instead (catch it)
- Error might be logged but not added to MessageFacade
- Service might be returning error in result Map
// Check all possibilities
then:
// Check MessageFacade
ec.message.hasError() ||
// Check result Map
result.error != null ||
// Check if exception was thrown
thrown(Exception)
Problem: Can’t Find Specific Validation Error
Debug:
then:
// Print all errors for debugging
println "Errors: ${ec.message.errors}"
println "Validation Errors: ${ec.message.validationErrors}"
ec.message.validationErrors.each { ve ->
println "Field: ${ve.field}, Message: ${ve.message}"
}
ec.message.hasError()
Quick Reference
Error Checking Methods
| Method |
Returns |
Use Case |
ec.message.hasError() |
boolean |
Check if any error exists |
ec.message.errors |
List<String> |
Get all error messages |
ec.message.errorsString |
String |
Get concatenated error text |
ec.message.validationErrors |
List<ValidationError> |
Get field-specific errors |
Assertions Examples
// Check error exists
ec.message.hasError()
// Check error count
ec.message.errors.size() == 2
// Check specific error message
ec.message.errors.contains("Email is required")
// Check error contains substring
ec.message.errorsString.contains("invalid")
// Check validation error for field
ec.message.validationErrors.any { it.field == "email" }
// Check no errors
!ec.message.hasError()
Cleanup Methods
ec.message.clearErrors() // Clear errors only
ec.message.clearAll() // Clear all messages
ec.message.clearMessages() // Clear non-error messages
Summary
Key Takeaways:
Moqui services use ec.message for errors, not exceptions
Always check ec.message.hasError() after service calls
Clear messages between tests to prevent leakage
Test specific error messages for better coverage
Use thrown() only for exception-throwing services (rare)
Testing Formula:
when:
// Call service that should fail
ec.service.sync().name("serviceName").parameters([...]).call()
then:
// Check for error
ec.message.hasError()
ec.message.errorsString.contains("expected error text")
cleanup:
// Always clean up
ec.message.clearErrors()
See Also