Most RPG code I have seen is tested the same way: compile, call the program with a few records, look at the report or the screen, and compare by eye. That works until someone changes a rule six months later and nobody remembers which records proved it.

iRPGUnit gives RPG the same kind of unit tests other languages have had for years. This article shows a real suite of 20 tests, what the report looks like when everything passes, and, more useful, what it looks like when something is wrong. Everything below was run on PUB400 (IBM i 7.5, iRPGUnit v6.0.5) and the output is copied from those runs.

First, something you can test

A unit test calls a procedure with known input and checks the result. That only works if the rule lives in a procedure that does not read a file, write a record or show a screen.

In legacy code that is rarely the case. In my modernization showcase the payment rules of an accounts receivable application were buried in the calculation specs of two programs, between CHAINs and indicators. Step 3 of the modernization moved them into a service program, ARSRV, with six small procedures and no I/O:

// Age bucket 1-5 for a number of days past due dcl-proc AR_ageBucket export; dcl-pi *n int(10); daysPastDue int(10) const; end-pi; select; when daysPastDue <= 0; return AR_BUCKET_CURRENT; when daysPastDue <= 30; return AR_BUCKET_1_30; when daysPastDue <= 60; return AR_BUCKET_31_60; when daysPastDue <= 90; return AR_BUCKET_61_90; other; return AR_BUCKET_OVER_90; endsl; end-proc; 

The programs that read the files now call these procedures. The tests call them too, without any files at all. This is the part that takes the real work. Writing the tests afterwards is quick.

What a test suite looks like

An iRPGUnit test suite is a NOMAIN module. Every exported procedure whose name starts with test is one test case. You include the iRPGUnit prototypes and the prototypes of the code under test:

**FREE ctl-opt nomain option(*srcstmt:*nodebugio); /copy RPGUNIT/QINCLUDE,TESTCASE /copy QRPGLESRC,ARSRVPR dcl-proc test_validate_amount_checked_first export; // legacy order: an invalid amount wins over an unknown customer aEqual(AR_MSG_INVALID_AMOUNT : AR_validatePayment(0 : *off)); end-proc; dcl-proc test_days_over_leap_day export; iEqual(2 : AR_daysPastDue(d'2024-03-01' : 20240228)); end-proc; dcl-proc test_bucket_boundaries export; iEqual(AR_BUCKET_CURRENT : AR_ageBucket(-19)); iEqual(AR_BUCKET_CURRENT : AR_ageBucket(0)); iEqual(AR_BUCKET_1_30 : AR_ageBucket(1)); iEqual(AR_BUCKET_1_30 : AR_ageBucket(30)); iEqual(AR_BUCKET_31_60 : AR_ageBucket(31)); iEqual(AR_BUCKET_31_60 : AR_ageBucket(60)); iEqual(AR_BUCKET_61_90 : AR_ageBucket(61)); iEqual(AR_BUCKET_61_90 : AR_ageBucket(90)); iEqual(AR_BUCKET_OVER_90 : AR_ageBucket(91)); end-proc; 

(Replace RPGUNIT with the library where iRPGUnit is installed.) Two commands do the rest:

RUCRTRPG TSTPGM(ARSRVT) SRCFILE(QRPGLESRC) BNDSRVPGM(ARSRV) RUCALLTST TSTPGM(ARSRVT) DETAIL(*ALL) 

RUCRTRPG compiles the module and creates the test service program bound to the code under test. RUCALLTST runs every test procedure and writes a spooled file named RPGUNIT.

The full suite has 20 test cases: every validation rule, every bucket boundary (0/1, 30/31, 60/61, 90/91), a leap day, a payment larger than the invoice, and one test for a defect the legacy program really had (more on that below). The last line of the report:

Success. 20 test cases, 28 assertions, 0 failure, 0 error. 

What a failure looks like

A green report proves less than it seems. The useful question is: if someone breaks a rule, does a test notice?

So I made a copy of the service program, ARSRVX, with one character changed:

 when daysPastDue < 30; // DELIBERATE DEFECT: should be <= 30 

An invoice exactly 30 days overdue now lands in the 31-60 bucket. On a real aging report that moves money from one column to the next, and the totals still add up, so nobody sees it. I bound the same, unchanged test suite to the broken copy and ran it:

TEST_BUCKET_BOUNDARIES - FAILURE Expected 2, but was 3. TEST_BUCKET_BOUNDARIES (ARSRVTX->ARSRVTX:9400) Expected: 1 '2' Actual: 1 '3' ----------------------- ... FAILURE. 20 test cases, 23 assertions, 1 failure, 0 error. 

Three things in this report are worth knowing:

  1. It points at the line. 9400 is the source statement (94.00, thanks to option(*srcstmt)) of the assertion that failed: iEqual(AR_BUCKET_1_30 : AR_ageBucket(30)).
  2. A failed assertion ends the test case. The suite had 28 assertions when it passed, and only 23 ran this time. The boundary test stopped at its fourth assertion, so the five after it never ran. If one test checks many things, you only learn about the first one that is wrong. That is a reason to keep test cases small, or at least to put the cheapest checks first.
  3. The other 19 tests stayed green. The failure names the rule that broke, not just "something changed".

Failure, error, and an exception you expect

iRPGUnit separates two kinds of red. To see both, I wrote a small demo suite (RUDEMOT) with one of each:

// a wrong expectation on purpose: what a FAILURE looks like dcl-proc test_failure_on_purpose export; aEqual('A' : AR_customerStatus('H' : 0.01) : 'status'); end-proc; // an unexpected exception: what an ERROR looks like dcl-proc test_error_on_purpose export; dcl-s zero int(10) inz(0); dcl-s x int(10); x = 100 / zero; end-proc; 

The report:

TEST_ERROR_ON_PURPOSE - ERROR MCH1211 - Attempt made to divide by zero for fixed point operation. TEST_ERROR_ON_PURPOSE (RUDEMOT->RUDEMOT:6100) ----------------------- TEST_FAILURE_ON_PURPOSE - FAILURE status: Expected 'A', but was 'H'. TEST_FAILURE_ON_PURPOSE (RUDEMOT->RUDEMOT:5300) Expected: 1 'A' Actual: 1 'H' 

A failure means the code ran and returned the wrong answer. An error means the code crashed. The third parameter of aEqual ('status') is a field name that is put in front of the message. With several assertions in one test it tells you which value was wrong without opening the source.

Sometimes the exception is the expected result. What does AR_daysPastDue do with a due date of 2026-13-32 from bad data? It should not return a number. You can test that with monitor and getMonitoredMessage:

dcl-proc test_bad_due_date_is_an_exception export; dcl-ds msg likeds(MsgInfo_t); dcl-s days int(10); monitor; days = AR_daysPastDue(d'2026-10-31' : 20261332); fail('AR_daysPastDue accepted 2026-13-32'); on-error; msg = getMonitoredMessage(*on); endmon; aEqual('RNX0112' : msg.id); end-proc; 

This test passed: the procedure ends with RNX0112 (date, time or timestamp value is not valid). If someone later "fixes" the procedure so it quietly returns 0 for a bad date, the fail() line turns this test red, and the team has to decide on purpose whether that is the behavior they want.

setUp, tearDown, and the order tests run in

iRPGUnit calls four optional procedures if you export them: setUpSuite and tearDownSuite once around the whole suite, and setUp and tearDown around every test. They are the place to create test data, clear a work file, or reset a data area.

I wanted to see the exact order, so each procedure in the demo suite appended one word to a data area. In the source, the tests are written in this order: numeric, failure, error, baddate. The data area after the run:

setUpSuite setUp baddate tearDown setUp error tearDown setUp failure tearDown setUp numeric tearDown tearDownSuite 

Two things:

  • tearDown ran after the failed test and after the crashed test too. Cleanup in tearDown is safe even when a test goes wrong. In the failing test, the line after the failed aEqual never ran (there is no after-failed-assert in the trace).
  • The tests ran in alphabetical order of their names, not in source order. The report is sorted the same way. Do not write a test that depends on another test having run first. If two tests need the same data, create it in setUp.

Small things that cost me time

Comparing numbers as strings. One of my first tests was:

aEqual('.00' : %char(AR_applyAmount(0 : 500.00))); // %char drops the leading zero 

It passes, but only because I wrote '.00' after seeing what %char returns. iRPGUnit 6 (the version on PUB400) also has an overloaded assertEqual that compares numbers as numbers, which says what you mean. Both lines below passed in the demo suite:

assertEqual(0 : AR_applyAmount(0 : 500.00)); assertEqual(300.00 : AR_applyAmount(300.00 : 500.00)); 

Testing the bug you fixed. The legacy report program printed *UNKNOWN* (FICTIONAL) for a missing customer, because a MOVEL of *UNKNOWN* left the end of the previous customer's name in the field. The fix is one line. The test that keeps it fixed is just as short:

dcl-proc test_unknown_customer_has_no_leftover_name export; aEqual('*UNKNOWN*' : %trimr(AR_customerName(*off : 'EKA FOODS (FICTIONAL)'))); end-proc; 

Every defect you fix is a test case you already know you need.

Running the tests in CI

RUCALLTST ends with escape message CPF9897 when any test fails or errors (FAILURE. 20 test cases, 23 assertions, 1 failure, 0 error.). Called through the system command in PASE, that is a non-zero exit code, which is all a CI pipeline needs. In my ibmi-inventory project, a GitHub Actions job builds the code from Git with IBM Bob (makei) and then runs this over SSH on the IBM i:

system "$RULIB/RUCRTRPG TSTPGM($OBJLIB/DATEUTILT) SRCSTMF('$PWD/qrpglesrc/dateutilt.rpgle') BNDSRVPGM(($OBJLIB/DATEUTIL)) TGTCCSID(*JOB)" system "$RULIB/RUCALLTST TSTPGM($OBJLIB/DATEUTILT) DETAIL(*ALL)" 2>&1 | tee .logs/rpgunit.txt grep -q "Success" .logs/rpgunit.txt 

The script runs with set -euo pipefail, so the escape message from a failed run stops it even though the output goes through tee (without pipefail, the exit code of that line would be tee's, and a red test would pass the pipeline). The last grep is a second check: the job is green only if the report really says Success. The report goes into the job's artifacts, so the reviewer of a pull request sees which test went red without signing on to the IBM i.

The rules I follow

  1. Move the rule out of the I/O first. A procedure with no files is easy to test. A program full of CHAINs is not.
  2. Test the boundaries, not the middle. 0, 1, 30, 31. The off-by-one in this article was caught only by the test for exactly 30.
  3. Break the code on purpose once. If no test goes red, the suite proves less than it seems.
  4. Keep test cases small. A failed assertion ends the test, so the checks after it tell you nothing.
  5. Never depend on test order. Shared data goes in setUp, cleanup in tearDown.
  6. Every fixed defect gets a test. It is the cheapest test to write and the one most likely to matter again.
  7. Let the pipeline run them. Tests that need a person to remember to run them stop being run.

The full suite, the service program and the regression outputs are in the modernization showcase repository. If your shop runs RPG unit tests in a pipeline, I would like to hear how you handle tests that need database data: a test library per run, setUp with SQL inserts, or something else.