How test frameworks become the "environment" for learning code agents. From binary pass/fail to differentiated multi-signal reward – and why the feedback pipeline Agent → Code → Test → Score → Agent forms the core of every self-learning system.
1. Tests as Environment
In the previous article, we built the RL framework for code agents: State, Action, Reward, Policy. But one central question remained open: Where does the reward signal come from in practice?
The answer is surprisingly straightforward: Automated tests.
"Tests are the first line of feedback and defense against coding mistakes, refactoring mistakes, and so on." — Fabrizio Romano, Learn Python Programming, 4th Ed., p. 472
In the world of code agents, test suites take on the role that the physical environment plays in classical RL. The agent generates code (action), the tests evaluate the result (environment), and the test outcome becomes the reward signal:
Agent → Generate code → Run tests → Result → Compute reward → Agent
This is not an abstract concept – it is exactly the loop that TDD (Test-Driven Development) has practiced for decades:
"TDD is a software development methodology that is based on the continuous repetition of a very short development cycle." — Fabrizio Romano, Learn Python Programming, 4th Ed., p. 472
The difference: In TDD, a human sits in the loop. In self-learning agents, the loop is automated – and the test result becomes a formal reward signal.
2. The Problem with Binary Feedback
The simplest approach: All tests pass → Reward 1.0. At least one test fails → Reward 0.0.
def binary_reward(suite: SuiteResult) -> float:
return 1.0 if suite.all_passed else 0.0
This works – but it's a sparse reward signal. Why this is problematic:
| Scenario | Pass Rate | Binary Reward |
|---|---|---|
| 0 of 10 tests pass | 0% | 0.0 |
| 9 of 10 tests pass | 90% | 0.0 |
| 10 of 10 tests pass | 100% | 1.0 |
An agent with 9 out of 10 passing tests receives the same feedback as one with 0 out of 10. From an RL perspective, this is fatal: The agent cannot detect a gradient toward improvement.
"A key innovation in RLHF is its approach to handling the high cost of human feedback. Rather than requiring constant human oversight, RLHF allows for asynchronous and sparse feedback." — Labonne, M. et al. (2025), LLM Engineer's Handbook, p. 331
What is acceptable for human feedback – sparsity – becomes an unnecessary bottleneck with automated tests. We have detailed information. We just need to use it.
3. Structured Test Results
The first step toward better reward: Treat test results not as booleans, but as structured data.
class TestStatus(Enum):
PASSED = "passed"
FAILED = "failed"
ERROR = "error"
SKIPPED = "skipped"
class TestSeverity(Enum):
CRITICAL = 3
MAJOR = 2
MINOR = 1
@dataclass(frozen=True)
class TestResult:
name: str
status: TestStatus
severity: TestSeverity = TestSeverity.MAJOR
message: str = ""
duration_ms: float = 0.0
Each individual test now carries three dimensions of information: - Status: Passed, failed, error, or skipped - Severity: How critical is this test? (A failed login test weighs more than a missing tooltip) - Context: Error message and execution duration
Aggregation into a suite yields a rich data structure:
@dataclass(frozen=True)
class SuiteResult:
tests: tuple[TestResult, ...]
suite_name: str = "default"
@property
def pass_rate(self) -> float:
active = self.total - self.skipped
return self.passed / active if active > 0 else 0.0
Note: Skipped tests are excluded from the pass rate. A skip is not a statement about code quality – it's a statement about test scope.
4. From Single Signal to Reward Vector
With structured test results, we can extract four different reward signals:
4.1 Pass Rate: Proportional Instead of Binary
def pass_rate_reward(suite: SuiteResult) -> float:
return suite.pass_rate
Much better: 9/10 → 0.9, 5/10 → 0.5. The agent now sees a gradient.
4.2 Severity-Weighted: Critical Tests Count More
def severity_weighted_reward(suite: SuiteResult) -> float:
active = [t for t in suite.tests if t.status != TestStatus.SKIPPED]
if not active:
return 0.0
total_weight = sum(t.severity.value for t in active)
passed_weight = sum(
t.severity.value for t in active
if t.status == TestStatus.PASSED
)
return passed_weight / total_weight
When a CRITICAL test (weight 3) fails and a MINOR test (weight 1) passes, the weighted score is lower than with a simple pass rate. This reflects reality: A missing tooltip is less severe than a broken login.
4.3 Progression: Rewarding Improvement
def progression_reward(
current: SuiteResult,
previous: SuiteResult | None,
) -> float:
if previous is None:
return current.pass_rate
return current.pass_rate - previous.pass_rate
The delta between the current and previous iteration. An agent that improves from 3/10 to 7/10 receives a positive signal (+0.4), even though not all tests pass yet. An agent that regresses from 8/10 to 5/10 receives a negative signal (−0.3).
4.4 Speed: Faster Tests as Bonus
def speed_score(suite: SuiteResult, target_ms: float = 1000.0) -> float:
if not suite.tests:
return 0.0
total_ms = sum(t.duration_ms for t in suite.tests)
return math.exp(-total_ms / target_ms)
Exponential decay relative to a target value. Code that passes all tests quickly is better than code that passes all tests slowly. This prevents brute-force solutions and rewards efficient implementations.
5. Multi-Signal Reward Shaping
Each individual signal has blind spots. The pass rate ignores severity levels. Progression ignores the absolute state. The speed score ignores correctness. Only the combination yields a complete picture.
@dataclass(frozen=True)
class RewardSignal:
pass_rate: float
severity_score: float
progression: float
coverage_score: float
speed_score: float
total: float
The weighting is configurable:
@dataclass(frozen=True)
class RewardWeights:
pass_rate: float = 0.35
severity: float = 0.25
progression: float = 0.20
coverage: float = 0.10
speed: float = 0.10
The default weighting reflects priorities: - Pass Rate (35%): The most important metric – does the code work? - Severity (25%): Critical failures weigh more heavily - Progression (20%): Improvement is rewarded, even with incomplete solutions - Coverage (10%): Bonus for broad test coverage - Speed (10%): Bonus for efficient solutions
"Reward hacking: Models may exploit loopholes in the reward function." — Vaid, S. & Lund, B. (2025), LLM Design Patterns, p. 452
The weighting is deliberately not focused on a single metric. Reward hacking – exploiting weaknesses in the reward function – is made harder by multi-signal scoring. An agent that only optimizes pass rate while writing catastrophic code is penalized by the severity and speed scores.
6. The Feedback Pipeline
The reward signal alone is not enough. The agent needs structured feedback that tells it what went wrong and where to start fixing.
@dataclass(frozen=True)
class FeedbackEntry:
iteration: int
submission: CodeSubmission
suite_result: SuiteResult
reward: RewardSignal
failing_tests: tuple[tuple[str, str], ...]
Each iteration produces a FeedbackEntry with:
1. The submitted code – what did the agent try?
2. The complete test result – which tests passed, which didn't?
3. The multi-signal reward – how good was the attempt overall?
4. The failing tests with messages – concrete hints for correction
The build_feedback function orchestrates the computation:
def build_feedback(
submission: CodeSubmission,
suite: SuiteResult,
previous_suite: SuiteResult | None = None,
coverage: float = 0.0,
target_ms: float = 1000.0,
weights: RewardWeights | None = None,
) -> FeedbackEntry:
reward = compute_reward_signal(
suite, previous_suite, coverage, target_ms, weights
)
failing = extract_failing_tests(suite)
return FeedbackEntry(
iteration=submission.iteration,
submission=submission,
suite_result=suite,
reward=reward,
failing_tests=failing,
)
7. The Agent-Test Loop
All components together form the Agent-Test Loop – the central architecture of self-learning code agents:
@dataclass
class AgentTestLoop:
test_runner: Callable[[str], SuiteResult]
agent_fn: Callable[[list[FeedbackEntry], str | None], str]
max_iterations: int = 10
reward_weights: RewardWeights = field(default_factory=RewardWeights)
def run(self, task: str | None = None) -> list[FeedbackEntry]:
history: list[FeedbackEntry] = []
previous_suite: SuiteResult | None = None
for i in range(1, self.max_iterations + 1):
code = self.agent_fn(history, task)
submission = CodeSubmission(
code=code, iteration=i,
strategy=_infer_strategy(history),
)
suite = self.test_runner(code)
entry = build_feedback(
submission, suite,
previous_suite=previous_suite,
weights=self.reward_weights,
)
history.append(entry)
previous_suite = suite
if suite.all_passed:
break
return history
The architecture is deliberately minimal and compositional:
test_runner: Any function that accepts code and returns aSuiteResult. This can be pytest, unittest, Playwright, or a custom runner.agent_fn: Any function that receives the history and a task, returning new code. This can be an LLM API call, a template engine, or a rule-based system.max_iterations: Safety limit against infinite loops.
The loop stops under two conditions: 1. All tests pass – the agent has solved the task 2. Max iterations reached – the agent is not converging
Strategy Inference
A subtle detail: The strategy is automatically derived from the history:
def _infer_strategy(history: list[FeedbackEntry]) -> str:
if not history:
return "initial"
last = history[-1]
if last.suite_result.errors > 0:
return "fix_errors"
if last.suite_result.failed > 0:
return "fix_failures"
return "refactor"
- No history → "initial" (first attempt)
- Runtime errors → "fix_errors" (code crashes)
- Test failures → "fix_failures" (code runs, but wrong results)
- All passing → "refactor" (optimization)
This strategy inference provides the agent with a hint about what kind of change is sensible next – without restricting its freedom of choice.
8. Learning Curves and Convergence
The feedback history is not only input for the agent – it is also a diagnostic tool for the quality of reward shaping.
@dataclass(frozen=True)
class LearningPoint:
iteration: int
pass_rate: float
total_reward: float
cumulative_reward: float
tests_fixed: int
The learning curve shows: - Pass rate over iterations: Is it monotonically increasing? → Good reward signal - Cumulative reward: Is it growing steadily? → Stable learning process - Tests fixed per iteration: Are new tests being fixed each round? → Progress, not oscillation
def convergence_rate(history: list[FeedbackEntry]) -> float | None:
for entry in history:
if entry.suite_result.all_passed:
return float(entry.iteration)
return None
The convergence rate – at which iteration all tests pass – is the ultimate metric. Lower values = better agent or better reward shaping.
A moving average of the reward smooths outliers and reveals the overall trend:
def reward_trend(history: list[FeedbackEntry], window: int = 3) -> list[float]:
rewards = [e.reward.total for e in history]
smoothed = []
for i in range(len(rewards)):
start = max(0, i - window + 1)
chunk = rewards[start:i + 1]
smoothed.append(sum(chunk) / len(chunk))
return smoothed
9. Comparison: Reward Strategies in Practice
| Strategy | Strength | Weakness | Use Case |
|---|---|---|---|
| Binary | Simple, clear | Sparse, no gradient | Final validation |
| Pass Rate | Gradient present | Ignores severity levels | Quick prototyping |
| Severity-Weighted | Prioritizes critical tests | Requires severity annotation | Production code |
| Progression | Rewards improvement | Unstable with oscillation | Learning phase |
| Multi-Signal | Robust overall picture | More complex configuration | Production use |
The recommendation: Start with pass rate, switch to multi-signal once the agent is fundamentally working. Binary reward only as final acceptance.
10. Conclusion
Automated tests are the natural reward signal for code agents:
- Structured test results transform binary pass/fail into an informative data type
- Severity weighting ensures that critical failures carry more weight
- Progression rewards every improvement, even when the solution is still incomplete
- Multi-signal shaping combines five dimensions into a robust overall reward
- The feedback pipeline provides the agent not just a score, but concrete feedback for correction
- Learning curves make progress visible and diagnosable
The core architecture – AgentTestLoop with interchangeable test runner and agent function – is deliberately kept minimal. It enforces no particular test technology and no specific LLM. The intelligence resides not in the loop, but in the reward shaping and the feedback format.
The next article in the series covers Memory & Experience Replay for Agents – how agents store successful strategies, retrieve them for similar tasks, and learn across tasks.
Sources & Resources
- Romano, F. (2024): Learn Python Programming. 4th Ed. Packt. pp. 472 ff. – TDD as feedback loop, test methodology, pytest architecture.
- Vaid, S. & Lund, B. (2025): LLM Design Patterns. Packt. pp. 452 ff. – Reward hacking, RLHF challenges, constrained optimization.
- Labonne, M. et al. (2025): LLM Engineer's Handbook. Packt. pp. 331 ff. – RLHF, sparse feedback, reward models, policy optimization.
- Ramirez, J. (2024): Generative AI with LangChain. 2nd Ed. Packt. pp. 440 ff. – Evaluating LLM output, format validation, scoring metrics.
- Labonne, M. et al. (2025): LLM Engineer's Handbook. Packt. pp. 580 ff. – Linting as code quality metric, static analysis.
- Sutton, R. S. & Barto, A. G. (2018): Reinforcement Learning: An Introduction. 2nd Ed. MIT Press. – Reward shaping, exploration vs. exploitation.
→ Full source code: src/