Somewhere in your RPG codebase is a program that calculates something your organization cannot afford to get wrong, and this week someone has to change it. The author retired. There are no tests. The plan is to make the change, run it against a copy of last month's data, and have the one person who still knows what the totals should be look them over. Most IBM i teams work this way - not because they doubt automated testing, but because on this platform a test suite has always meant more jobs on a machine that is already busy, more test data to stage, and one more environment to babysit. Most of that cost is not the tests. It is where the tests have to run.
Unit Testing RPG with RPGUnit
Most RPG teams know RPGUnit, or at least know of it - the xUnit-style framework that IBM's VS Code testing extension is built on. A test suite is an RPG module; a test case is an exported procedure whose name starts with test.
Here is what that looks like in practice, using empdet from IBM's Company System sample - a service module with exported procedures and embedded SQL. You have modules like it in production right now. This test checks that getDeptDetail finds department A00 and totals its salaries correctly:
qtestsrc/empdet.test.sqlrpgle
dcl-proc test_getDeptDetail_found export;
dcl-pi *n extproc(*dclcase) end-pi;
dcl-s deptno char(3);
dcl-ds actual likeDs(department_detail_t) inz;
deptno = 'A00';
actual = getDeptDetail(deptno);
nEqual(*on : actual.found : 'found');
assert(actual.deptname = 'SPIFFY COMPUTER SERVICE DIV.' : 'deptname');
assert(actual.location = 'NEW YORK' : 'location');
assert(actual.totalsalaries = 90160 : 'totalsalaries');
end-proc;
The test itself is the easy part: call the procedure, check the result. The setup is where the platform shows through. Before the first assertion runs, EMPLOYEE and DEPARTMENT need to contain exactly the rows the tests expect - not whatever the last job to use the test library left in them. On the IBM i, isolation means duplicating each table into QTEMP and overriding the file references for the job:
qtestsrc/empdet.test.sqlrpgle
dcl-proc setupMockTable;
dcl-pi *n;
table varchar(10) const;
end-pi;
dcl-s cmd varchar(5000);
cmd = 'CRTDUPOBJ OBJ(' + table + ') FROMLIB(*LIBL) ' +
'OBJTYPE(*FILE) TOLIB(QTEMP) NEWOBJ(' + table + ')';
exec sql
call qsys2.qcmdexc(:cmd);
cmd = 'OVRDBF FILE(' + table + ') ' +
'TOFILE(QTEMP/' + table + ') OVRSCOPE(*JOB)';
exec sql
call qsys2.qcmdexc(:cmd);
end-proc;
Error handling trimmed, and it is still a procedure that builds CL command strings and calls QCMDEXC so one test suite can get a private copy of two tables. It works - teams run suites exactly like this every day. But every piece of it, from the compile with RUCRTRPG to the run with RUCALLTST, happens on the IBM i, because it can happen nowhere else.
CI inherits that. A GitHub Actions workflow or a Jenkins job for RPGUnit is an SSH connection into a partition: push the source, compile it there, run the tests there, pull the results back - host credentials in the repository secrets, and every developer's push arriving as more jobs on the machine you size carefully and license by the core. Elsewhere, CI works because runners are disposable: created for one build, destroyed after it, nothing left to contaminate the next. There is no disposable IBM i. QTEMP-per-job is as close as it gets, and you just read what that takes.
Unit Testing RPG with JUnit
There is an alternative: write the unit tests in JUnit and run them on a GitHub Actions runner or a Jenkins agent - no SSH into a partition, no host credentials in the secrets, no test jobs on the machine. Triton RPG compiles empdet.sqlrpgle - source unchanged, still maintained by your RPG team - to JVM bytecode, and its exported procedures become callable Java methods. JUnit neither knows nor cares that the code under test began as RPG. Here is the same test:
qtestsrc/EmpdetTest.java
@Test
void getDeptDetail_found() {
EMPDET e = new EMPDET();
e.setActivationGroup(new RpgActivationGroup());
e._initSql(conn);
var r = e.getDeptDetail(new RpgString("A00", 3));
assertTrue(r.found);
assertEquals("SPIFFY COMPUTER SERVICE DIV.", r.deptname);
assertEquals("NEW YORK", r.location.trim());
assertEquals(0, new BigDecimal("90160.00")
.compareTo(r.totalsalaries.toBigDecimal()));
}
Same procedure, same department, same expected total of 90160.00. The result object's fields - found, deptname, totalsalaries - are named for the RPG procedure's own return structure, so the assertions read against the names your RPG programmer wrote.
And the QTEMP duplication becomes a connection string:
qtestsrc/EmpdetTest.java
@BeforeAll
static void createSchema() throws Exception {
conn = DriverManager.getConnection("jdbc:h2:mem:test;MODE=DB2");
try (Statement s = conn.createStatement()) {
s.execute("CREATE TABLE EMPLOYEE (EMPNO CHAR(6) NOT NULL, ...)");
s.execute("CREATE TABLE DEPARTMENT (DEPTNO CHAR(3) NOT NULL, ...)");
// the same fixture rows the RPGUnit suite loads into QTEMP
s.execute("INSERT INTO EMPLOYEE VALUES ('000010', 'CHRISTINE', ...)");
s.execute("INSERT INTO DEPARTMENT VALUES ('A00', ...)");
}
}
The database is H2, in memory, in DB2 compatibility mode. The suite creates it, loads the same five fixture rows the RPGUnit suite uses, and throws it away when the JVM exits. Every run starts clean. Two branches can test at the same moment without seeing each other, because each run has its own database. Nothing to duplicate, nothing to override, nothing left behind.
The same suite also runs against the real database. Hand the test class a jt400 connection to a partition instead of the H2 URL and the assertions run against your Db2 for i - the tests do not change, the JDBC URL does. That gives you two tiers from one suite: the in-memory tier on every pull request, and the Db2 for i tier nightly or before a release.
RPG Unit Tests in GitHub Actions
The pipeline is three commands: compile the RPG, compile the test, run the suite.
test.sh
rpgc --lib --include-path qrpgleref --output out qrpglesrc/empdet.sqlrpgle
javac -cp "out:triton-rpg.jar:junit-console.jar:h2.jar" \
-d out qtestsrc/EmpdetTest.java
java -jar junit-console.jar execute \
--classpath "out:triton-rpg.jar:h2.jar" \
--select-class EmpdetTest
Put them in a script, and the workflow is the boring kind:
.github/workflows/unit-tests.yaml
name: Unit Tests
on: pull_request
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
- run: ./test.sh
That is a stock ubuntu-latest runner. No secrets, no SSH, no partition to reach - and no partition that can be down, busy, or mid-backup when your team pushes code. The same script runs on a Jenkins agent with nothing installed but a JDK, or on a laptop on an airplane. Here is the run:
EmpdetTest ✔
├─ getEmployeeDetail_found() ✔
├─ getEmployeeDetail_notFound() ✔
├─ getDeptDetail_found() ✔
└─ getDeptDetail_notFound() ✔
Test run finished after 283 ms
[ 4 tests successful ]
[ 0 tests failed ]
The RPG compiles from source in about half a second. The four tests finish in 283 milliseconds. The whole script takes under two seconds. A suite that costs two seconds runs on every change, not every release.
Start With One Module
RPGUnit suites you have already written carry over. The two suites above are ports of each other: the fixture rows became INSERT statements, nEqual and assert became assertEquals and assertTrue, and the procedure calls stayed procedure calls.
Nothing else has to move. The tests run against the compiled module on a build server; the same programs keep running on the IBM i, along with the green screens, the batch jobs, and the data. Because the compiler is deterministic, when both suites pass, the same logic passed in both places. Pick a service module and start there.
Want unit tests for your RPG?
Start a Conversation