Testing with Cycles
This guide covers how to test code that uses the @cycles decorator (Python) or the @Cycles annotation (Java) and the CyclesClient interface.
Python
Unit testing @cycles-decorated functions
The @cycles decorator requires a client to function. In a unit test, you can test business logic by calling the underlying function directly without the decorator, or by mocking the client.
For plain function logic (without budget enforcement), test the function directly:
def test_business_logic():
result = call_llm("some text")
assert result == "expected output"Mocking CyclesClient with pytest
When testing code that uses CyclesClient programmatically, mock the client responses:
from unittest.mock import MagicMock, ANY
from runcycles import CyclesClient, CyclesResponse
import pytest
def test_successful_processing():
client = MagicMock(spec=CyclesClient)
# Mock reservation response
client.create_reservation.return_value = CyclesResponse.success(200, {
"reservation_id": "res-123",
"decision": "ALLOW",
"expires_at_ms": 1709312345678,
})
# Mock commit response
client.commit_reservation.return_value = CyclesResponse.success(200, {
"status": "COMMITTED",
})
result = process_document(client, "doc-1", "content")
assert result is not None
client.create_reservation.assert_called_once()
client.commit_reservation.assert_called_once()
def test_budget_denied():
client = MagicMock(spec=CyclesClient)
# Insufficient budget returns 409
client.create_reservation.return_value = CyclesResponse.http_error(
409, "Insufficient remaining balance",
body={"error": "BUDGET_EXCEEDED", "message": "Insufficient remaining balance"},
)
result = process_document(client, "doc-1", "content")
assert result == "Budget exhausted. Please try again later."
client.commit_reservation.assert_not_called()
def test_release_on_failure():
client = MagicMock(spec=CyclesClient)
client.create_reservation.return_value = CyclesResponse.success(200, {
"reservation_id": "res-123",
"decision": "ALLOW",
})
with pytest.raises(RuntimeError):
process_document_that_fails(client, "doc-1", "content")
# Verify budget was released
client.release_reservation.assert_called_once()Testing with pytest-httpx
For integration-style tests, use pytest-httpx to mock HTTP responses:
from runcycles import CyclesClient, CyclesConfig, ReservationCreateRequest
def test_full_lifecycle(httpx_mock):
httpx_mock.add_response(
method="POST",
url="http://localhost:7878/v1/reservations",
json={
"reservation_id": "res-test-001",
"decision": "ALLOW",
"expires_at_ms": 1709312345678,
"affected_scopes": ["tenant:test"],
},
status_code=200,
)
httpx_mock.add_response(
method="POST",
url="http://localhost:7878/v1/reservations/res-test-001/commit",
json={"status": "COMMITTED"},
status_code=200,
)
config = CyclesConfig(base_url="http://localhost:7878", api_key="test-key")
with CyclesClient(config) as client:
response = client.create_reservation(request)
assert response.is_success
assert response.get_body_attribute("reservation_id") == "res-test-001"Testing error handling
from runcycles import BudgetExceededError, CyclesProtocolError
def test_budget_exceeded_handling():
ex = BudgetExceededError(
"Budget exceeded",
status=409,
error_code="BUDGET_EXCEEDED",
)
assert ex.is_budget_exceeded()
assert not ex.is_reservation_expired()
assert ex.status == 409
def test_retry_after_handling():
ex = CyclesProtocolError(
"Try again later",
status=409,
error_code="BUDGET_EXCEEDED",
retry_after_ms=5000,
)
assert ex.retry_after_ms == 5000Testing async code
import pytest
from runcycles import AsyncCyclesClient, CyclesConfig
@pytest.mark.asyncio
async def test_async_reservation(httpx_mock):
httpx_mock.add_response(
method="POST",
url="http://localhost:7878/v1/reservations",
json={"reservation_id": "res-async-001", "decision": "ALLOW"},
status_code=200,
)
config = CyclesConfig(base_url="http://localhost:7878", api_key="test-key")
async with AsyncCyclesClient(config) as client:
response = await client.create_reservation(request)
assert response.is_successJava (Spring)
Unit testing @Cycles-annotated methods
The @Cycles annotation is driven by Spring AOP. In a plain unit test (without Spring context), the annotation has no effect — the method runs normally without any reservation lifecycle.
This means you can unit test the method's business logic without Cycles getting involved:
@Test
void testBusinessLogic() {
LlmService service = new LlmService(mockChatModel);
String result = service.summarize("some text");
assertEquals("expected output", result);
}No mocking of Cycles is needed for pure unit tests.
Mocking CyclesClient
When testing code that uses CyclesClient programmatically, mock the client:
@ExtendWith(MockitoExtension.class)
class DocumentProcessorTest {
@Mock
private CyclesClient cyclesClient;
@InjectMocks
private DocumentProcessor processor;
@Test
void testSuccessfulProcessing() {
Map<String, Object> reserveBody = Map.of(
"reservation_id", "res-123",
"decision", "ALLOW",
"expires_at_ms", System.currentTimeMillis() + 60000
);
when(cyclesClient.createReservation(any()))
.thenReturn(CyclesResponse.success(200, reserveBody));
Map<String, Object> commitBody = Map.of("status", "COMMITTED");
when(cyclesClient.commitReservation(eq("res-123"), any()))
.thenReturn(CyclesResponse.success(200, commitBody));
String result = processor.processDocument("doc-1", "content");
assertNotNull(result);
verify(cyclesClient).createReservation(any());
verify(cyclesClient).commitReservation(eq("res-123"), any());
}
@Test
void testBudgetDenied() {
when(cyclesClient.createReservation(any()))
.thenReturn(CyclesResponse.error(409, "BUDGET_EXCEEDED",
"Insufficient remaining balance"));
String result = processor.processDocument("doc-1", "content");
assertEquals("Budget exhausted. Please try again later.", result);
verify(cyclesClient, never()).commitReservation(any(), any());
}
@Test
void testReleaseOnFailure() {
Map<String, Object> reserveBody = Map.of(
"reservation_id", "res-123",
"decision", "ALLOW"
);
when(cyclesClient.createReservation(any()))
.thenReturn(CyclesResponse.success(200, reserveBody));
doThrow(new RuntimeException("LLM error"))
.when(mockLlm).call(any());
assertThrows(RuntimeException.class,
() -> processor.processDocument("doc-1", "content"));
verify(cyclesClient).releaseReservation(eq("res-123"), any());
}
}Integration testing with the @Cycles annotation
To test the full @Cycles lifecycle in a Spring context, mock the CyclesClient bean:
@SpringBootTest
class CyclesIntegrationTest {
@MockBean
private CyclesClient cyclesClient;
@Autowired
private LlmService llmService;
@Test
void testAnnotatedMethodWithAllow() {
Map<String, Object> reserveBody = Map.of(
"reservation_id", "res-test-001",
"decision", "ALLOW",
"expires_at_ms", System.currentTimeMillis() + 60000,
"affected_scopes", List.of("tenant:test"),
"scope_path", "tenant:test",
"reserved", Map.of("amount", 5000, "unit", "USD_MICROCENTS")
);
when(cyclesClient.createReservation(any()))
.thenReturn(CyclesResponse.success(200, reserveBody));
Map<String, Object> commitBody = Map.of(
"status", "COMMITTED",
"charged", Map.of("amount", 3200, "unit", "USD_MICROCENTS")
);
when(cyclesClient.commitReservation(any(), any()))
.thenReturn(CyclesResponse.success(200, commitBody));
String result = llmService.summarize("test input");
assertNotNull(result);
verify(cyclesClient).createReservation(any());
verify(cyclesClient).commitReservation(eq("res-test-001"), any());
}
@Test
void testAnnotatedMethodWithDeny() {
Map<String, Object> denyBody = Map.of(
"decision", "DENY",
"error", "BUDGET_EXCEEDED",
"message", "Insufficient budget"
);
when(cyclesClient.createReservation(any()))
.thenReturn(CyclesResponse.httpError(409, "Insufficient budget", denyBody));
assertThrows(CyclesProtocolException.class,
() -> llmService.summarize("test input"));
}
}Integration testing with a real Cycles server
For end-to-end tests, use Testcontainers to spin up Redis and the Cycles server:
@SpringBootTest
@Testcontainers
class FullIntegrationTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("cycles.base-url", () -> "http://localhost:7878");
registry.add("cycles.api-key", () -> "test-key");
registry.add("cycles.tenant", () -> "test-tenant");
}
@Autowired
private CyclesClient cyclesClient;
@Test
void testFullLifecycle() {
ReservationCreateRequest request = ReservationCreateRequest.builder()
.idempotencyKey("integration-test-001")
.subject(Subject.builder().tenant("test-tenant").build())
.action(new Action("test", "integration", null))
.estimate(new Amount(Unit.USD_MICROCENTS, 100L))
.build();
CyclesResponse<Map<String, Object>> response =
cyclesClient.createReservation(request);
assertTrue(response.is2xx());
}
}Testing CyclesFieldResolver implementations
Test custom field resolvers directly:
@Test
void testTenantResolver() {
RepositoryAccessService repoService = mock(RepositoryAccessService.class);
when(repoService.findTenant()).thenReturn(Optional.of("resolved-tenant"));
CyclesTenantResolver resolver = new CyclesTenantResolver();
ReflectionTestUtils.setField(resolver, "repositoryAccessService", repoService);
assertEquals("resolved-tenant", resolver.resolve());
}Testing SpEL expressions
Test that your SpEL expressions evaluate correctly:
@Test
void testEstimateExpression() {
CyclesExpressionEvaluator evaluator = new CyclesExpressionEvaluator();
Method method = LlmService.class.getMethod("generate", int.class);
Object[] args = { 500 };
long result = evaluator.evaluate("#p0 * 10", method, args, null, null);
assertEquals(5000, result);
}Tips
- Unit tests: test business logic without the decorator/annotation — it has no effect when bypassed
- Mock CyclesClient: use Python
MagicMockor Java@MockBeanto avoid needing a real server - Test both ALLOW and DENY paths: ensure your code handles budget denial gracefully
- Test error paths: verify release is called when functions/methods throw
- Use HTTP mocking for integration tests:
pytest-httpxfor Python, Testcontainers for Java - Python-specific: use
pytest-httpxfor sync andrespxfor async HTTP mocking
Next steps
- Error Handling in Python — Python exception handling patterns
- Error Handling Patterns — general error handling patterns
- Using the Client Programmatically — direct client usage
- SpEL Expression Reference — expression syntax (Java)
