The wonderful Symposium 42 community is always a source of inspiration, kindness and willingness to help. Many thanks to everyone for lending me an ear while ranting about utPLSQL (tongue in cheek of course). This post is for you.
Unit Testing and PL/SQL
utPLSQL is the most prominent unit test framework for PL/SQL as far as I know. And please don’t get me wrong, I love the framework and I use it in most of my demos, unit tests are too important not to implement, even in small demos. I only had concern with it, and it’s related to the way ut.run() works. It prints the test suite results on screen, but it cannot be tricked into raising an error in case one or more of your tests fail.
Why does that matter? Well, I use Continuous Integration (CI) Pipelines a lot, and I want the current job to fail in case it encounters errors. There’s a command line version for utPLSQL as well, but it hasn’t been updated in a long time and I prefer a different solution.
Here’s an example for a unit test
I borrowed the following example from the official website and ran it against an Oracle AI Database 26ai Free instance, after installing utPLSQL.
SQL> create or replace function betwnstr( a_string varchar2, a_start_pos integer, a_end_pos integer ) return varchar2 2 is 3 begin 4 return substr( a_string, a_start_pos, a_end_pos - a_start_pos + 1 ); 5 end; 6* /Function BETWNSTR compiledSQL> create or replace package test_betwnstr as 2 3 --%suite(Between string function) 4 5 --%test(Returns substring from start position to end position) 6 procedure basic_usage; 7 8 --%test(Returns substring when start position is zero) 9 procedure zero_start_position; 10 11 end; 12* /Package TEST_BETWNSTR compiledSQL> create or replace package body test_betwnstr as 2 3 procedure basic_usage is 4 begin 5 ut.expect( betwnstr( '1234567', 2, 5 ) ).to_equal('2345'); 6 end; 7 8 procedure zero_start_position is 9 begin 10 ut.expect( betwnstr( '1234567', 0, 5 ) ).to_equal('12345'); 11 end; 12 13 end; 14* /Package Body TEST_BETWNSTR compiled
Everything is set! Time to run the test suite:
SQL> set serveroutput onSQL> whenever sqlerror exit sql.sqlcode rollbackSQL> exec ut.runBetween string function Returns substring from start position to end position [.001 sec] Returns substring when start position is zero [.002 sec] (FAILED - 1)Failures: 1) zero_start_position Actual: '123456' (varchar2) was expected to equal: '12345' (varchar2) at "EMILY.TEST_BETWNSTR.ZERO_START_POSITION", line 10 ut.expect( betwnstr( '1234567', 0, 5 ) ).to_equal('12345');Finished in .004583 seconds2 tests, 1 failed, 0 errored, 0 disabled, 0 warning(s)PL/SQL procedure successfully completed.
The failure is expected (-> read the docs as to why). What I would have liked, is for ut.run() to throw an error so that a whenever sqlerror statement exits my session ungracefully and stops my pipeline execution
Solution
I cloned the GitHub project’s main branch, and started digging around. Well, in fairness, my virtual AI coding buddy did the hard work. After thinking for a bit he told me:
ut.run() is wired to never fail-on-test-failures:
- source/api/ut.pkb (line 21) sets gc_fail_on_errors constant boolean := false;
- source/api/ut.pkb (line 143) and source/api/ut.pkb (line 556) pass that value into ut_runner.run.
ut_runner.run() supports exactly what you want:
- Parameter a_fail_on_errors in spec: source/api/ut_runner.pks (line 69)
- Raises raise_application_error when result is failure/error: source/api/ut_runner.pkb (line 153)
- Error code is -20213: source/core/ut_utils.pks (line 88)
- Covered by test: test/ut3_user/api/test_ut_runner.pkb (line 423)
He also went ahead and suggested a code snippet to run, which, to me, looks very reasonable.
set serveroutput on size unlimitedwhenever sqlerror exit sql.sqlcode rollbackdeclare l_reporter ut_documentation_reporter := ut_documentation_reporter();begin begin ut_runner.run( a_paths => ut_varchar2_list(user), -- or your suite/package path a_reporters => ut_reporters(l_reporter), a_fail_on_errors => true ); treat(l_reporter as ut_output_reporter_base).lines_to_dbms_output(); exception when others then treat(l_reporter as ut_output_reporter_base).lines_to_dbms_output(); raise; -- non-zero exit for sqlplus/sqlcl end;end;/
Which is exactly what I needed. After storing the snippet in /tmp/test.sql, I ran it and sure enough, it bombed out of my SQLcl session.
SQL> @/tmp/testSQL> set serveroutput on size unlimitedSQL> whenever sqlerror exit sql.sqlcode rollbackSQL>SQL> declare 2 l_reporter ut_documentation_reporter := ut_documentation_reporter(); 3 begin 4 begin 5 ut_runner.run( 6 a_paths => ut_varchar2_list(user), -- or your suite/package path 7 a_reporters => ut_reporters(l_reporter), 8 a_fail_on_errors => true 9 ); 10 treat(l_reporter as ut_output_reporter_base).lines_to_dbms_output(); 11 exception 12 when others then 13 treat(l_reporter as ut_output_reporter_base).lines_to_dbms_output(); 14 raise; -- non-zero exit for sqlplus/sqlcl 15 end; 16 end; 17 /Between string function Returns substring from start position to end position [.008 sec] Returns substring when start position is zero [.003 sec] (FAILED - 1)Failures: 1) zero_start_position Actual: '123456' (varchar2) was expected to equal: '12345' (varchar2) at "EMILY.TEST_BETWNSTR.ZERO_START_POSITION", line 10 ut.expect( betwnstr( '1234567', 0, 5 ) ).to_equal('12345');Finished in .012853 seconds2 tests, 1 failed, 0 errored, 0 disabled, 0 warning(s)Rollbackdeclare*ERROR at line 1:ORA-20213: Some tests failedORA-06512: at line 14ORA-06512: at "UT3.UT_RUNNER", line 154ORA-06512: at line 5Disconnected from Oracle AI Database 26ai Free Release 23.26.1.0.0 - Develop, Learn, and Run for FreeVersion 23.26.1.0.0$ echo $?245
Nice! Both the test outcome and the ungraceful exit out of my session are clearly visible in the output.
Summary
If you want to use plain utPLSQL to exit your SQLcl/SQLPlus session, consider using the code snippet from earlier in this article. This way not only do you get to see the output of your tests, which is essential for troubleshooting, but you also get the additional benefit of dropping out of your session with a non-zero exit code, stopping your CI pipeline dead in the tracks. Which is also exactly what you want.
Happy coding!