The series Building a customer app in Uniface 10 ended its last round with 688 tests in 13 test services. This round added 40 features in one go - twelve new services, from a sales pipeline to settings snapshots - and two new test services. At the end, the full run had 15 suites and 872 tests, all passing, with the database file byte-identical before and after.
Part 17 of the series already covered the basics: every suite logs in on its own, every suite ends with rollback, and an MD5 of the database file before and after the run shows anything that slipped through. This article is about what came on top when the amount of work grew: how to keep 40 features in order, how to write tests that don't depend on what else is in the database, and how to make sure the IDE actually contains the code you tested.
Tested with Rocket Uniface 10.4.03 Community Edition (10.4.03.021) and the SQLite version of its SLE driver (3.50.2), October 2026.
1. A work list in a file
Forty features don't fit into anyone's head, including mine. Before writing any code, I put them into a Markdown file in the project folder: one table per stage, one row per feature, with the service operation, the required role and a status column.
| # | Status | Feature | Service.Operation | Right |
|---|---|---|---|---|
| 1 | [x] | Set the customer stage (LEAD, PROSPECT, CUSTOMER, LOST) with a history entry | PIPELINE_SVC.SET_STAGE | EDIT |
| 2 | [x] | Read the stage (stage, since when) | PIPELINE_SVC.GET_STAGE | READ |
...
Below the tables, the file has the rules learned in earlier rounds and a progress log with one line per finished stage: what was added, how many tests, which failures and why. The four stages - sales, data quality, statistics, operations - were done strictly one after the other: write the services, compile, show the compiler output before fixing anything, run the stage's tests, update the file. Only then the next stage.
It sounds bureaucratic. In practice it meant that after an interruption - and there were several - the next step was always clear, and at the end the documentation was mostly written already.
2. Each suite owns a range
All test services work on the same database file. To keep them from stepping on each other, each one owns a range of customer numbers, and its clean-up only touches that range:
| Suite | Customer numbers |
|---|---|
| FEAT9_TST_SVC | 999830 - 999859 |
| FEAT10_TST_SVC | 999860 - 999909 |
| FEAT11_TST_SVC | 999910 - 999959 |
Users, settings, templates and snapshots that tests create get a prefix (tst_ for users, TST_ for keys). The clean-up runs at the start - in case a previous run stopped half-way - and again at the end:
entry CLEANUP
sql "DELETE FROM CUSTOMER_STAGE WHERE CUSTOMER_ID BETWEEN 999860 AND 999909", "CUSTOMERS"
sql "DELETE FROM CUSTOMER_OWNER WHERE CUSTOMER_ID BETWEEN 999860 AND 999909", "CUSTOMERS"
...
sql "DELETE FROM CUSTOMER WHERE CUSTOMER_ID BETWEEN 999860 AND 999909", "CUSTOMERS"
sql "DELETE FROM APP_SETTING WHERE SETTING_KEY LIKE 'TST!_%' ESCAPE '!'", "CUSTOMERS"
sql "DELETE FROM TEXT_TEMPLATE WHERE TEMPLATE_KEY LIKE 'TST!_%' ESCAPE '!'", "CUSTOMERS"
call AS_RUNNER
return 0
end
Note the ESCAPE '!': in LIKE, the underscore is a wildcard for one character, so 'TST_%' would also match a real setting called TSTX.... The child tables come first and the customers last, so the order would still be right if foreign keys were enforced.
3. Measure first, then compare the difference
The database of this app is empty in normal operation, so a test could simply expect "the pipeline has 2 leads". That test would break the day someone runs the suite against a database with real data - or the day another suite leaves something behind. So the tests that count things measure first and compare the difference:
activate "PIPELINE_SVC".PIPELINE_SUMMARY(vData, vCount, vError)
call ROW_VALUE(vData, "LEAD", 2, vN0)
...
activate "PIPELINE_SVC".SET_STAGE(999861, "LEAD", vError)
activate "PIPELINE_SVC".SET_STAGE(999862, "lead", vError)
...
activate "PIPELINE_SVC".PIPELINE_SUMMARY(vData, vCount, vError)
call ROW_VALUE(vData, "LEAD", 2, vN)
vCount = vN - vN0
call CHECK("PIPELINE the summary counts the customers per stage", (vCount = 2), "lead before %%(vN0), after %%(vN)", pTests, pFailures)
The same pattern is used for statistics per month, follow-up statistics, consent rates, the export log, the period comparison and the clean-up of old sessions. Two details matter:
- If a group may not exist yet, the "before" value is empty, not
0. The tests set it to"0"explicitly before they calculate with it. - Where a value can only be checked on an empty database - an average, for example - the check says so:
(vN0 != "0" | vAvg = "5" | vAvg = "5.0"). It is weaker, but it does not lie.
4. A setting that leaked between tests
rollback runs once at the end of a suite, not after every test. Within a suite, every test sees what the tests before it left behind. That bit me with the ABC classification, whose limits come from two settings:
- the test for the settings check set
ABC_Ato50to provoke a warning, - the ABC test, which ran later in the same suite, only set
ABC_Band relied onABC_Ahaving its default of 70.
Run on its own, the ABC test passed. In the full suite it failed with "the limits come from the settings". The fix was one line - the ABC test now sets ABC_A as well - and a rule that went into the work list file: a test sets every setting it depends on, even if the value is the default.
5. Make sure the IDE has the code you tested
In this project, code is not typed into the IDE; it is written in files, pasted into the IDE's script editor and compiled. That leaves room for a quiet kind of error: a paste that landed one line too low, a selection that missed a line, a fix that went into the file but not into the IDE. The tests would then test the IDE's version, and the files - from which the documentation and these articles are written - would describe something else.
The Community Edition keeps its repository in a local SQLite file, dbms\usys.db. An earlier article, about building a calculator in the same IDE, describes what happens when you write to it while the IDE is running: the IDE stopped and would not start again. Reading a copy is harmless. So after every compile, a small script copies the file, extracts the script of each component and compares it with the file it came from:
mkdir -p ~/scratch/repo2 && cd ~/scratch/repo2 && cp "<ide project folder>/dbms/usys.db" usys.db && rm -rf src && python3 ../repo/extract.py usys.db
for n in "$@"; do python3 - "$n" <<'PY'
import sys,re,os,difflib
n=sys.argv[1]
t=open(os.path.expanduser('~/scratch/repo2/src/%s.txt'%n)).read()
d=re.split(r'^##### (\w+)\n',t,flags=re.M); dd=dict((d[i],d[i+1]) for i in range(1,len(d),2))
a=dd['USCRIPT'].rstrip('\n').split('\n'); b=open(os.path.expanduser('~/scratch/s18/out/%s.txt'%n)).read().rstrip('\n').split('\n')
a=[x.rstrip() for x in a]; b=[x.rstrip() for x in b]
diff=list(difflib.unified_diff(b,a,'expected','ide',lineterm='',n=0))
print(n,'IDENTICAL' if not diff else 'DIFF:'); print('\n'.join(diff[:40]))
PY
done
(The folder of the IDE project is replaced by a placeholder.) The extraction itself reads the component tables UFORM and OUFORM and unescapes the XML in which the scripts are stored. The output for this round:
PIPELINE_SVC IDENTICAL
OWNER_SVC IDENTICAL
...
FEAT11_TST_SVC IDENTICAL
PRIVACY_SVC IDENTICAL
It also works the other way round: before changing an existing component, the comparison shows whether the file is still the current version. For PRIVACY_SVC it showed exactly the twelve lines that were about to be inserted, and nothing else.
6. When a test fails, check the expectation first
Five things went red during this round. Three were compiler errors and entirely mine ($concat with six arguments - the limit is five). The two test failures were not bugs in the services at all:
- Trailing blanks. A test compared an e-mail address with blanks on both sides after reading it back into ProcScript. The trailing blank does not survive that trip. The service was right; the comparison was not.
-
No login is not no role. A test expected that adding a personal task fails after logout. In this app, a user who is not logged in has the role
READ- a deliberate decision from part 16 of the series - and a personal task only needsREAD. So the call succeeded, as designed. The test now checks what actually matters: after logout, the tasks of the previous user are not visible.
In both cases, "fix the service until the test is green" would have been the wrong reaction. The first question for a red test is whether it expects the right thing.
Results
- 12 new services, 7 new tables, one changed service (anonymizing a customer also removes stage, owner and contact rhythm).
- Two new test services with 114 and 70 tests. Full run: 15 suites, 872 tests, all passing.
- MD5 of the database file identical before and after the full run, no journal file left.
- All 16 new or changed components identical in the IDE and in the files.
Takeaways
Write the plan into a file and work through it. Status per feature, a progress log per stage, rules from earlier rounds at the top.
Give each test suite its own range of IDs and names, and clean up before and after.
Compare differences, not absolute numbers, unless the test creates everything it counts.
A test sets every setting it depends on. The suite's rollback comes too late to protect the next test.
Check that the tool contains what you tested. If the IDE's repository is readable, diff against it - read-only, on a copy.
A red test is a question, not an order. Check the expectation before you change the code.
Top comments (0)