Build, test and debug a macro library
A macro you use in one program is a shortcut. A macro the whole team uses is a product. Other people call it with input you did not expect, and a wrong number from it lands in every table. This chapter builds one small macro the way experienced clinical programmers build their library macros, then tests it.
19.1 The anatomy of a library macro
Section titled “19.1 The anatomy of a library macro”/******************************************************************************* Macro : %count_subj Version : 1.2 Purpose : Number of subjects (distinct USUBJID) by one or more variables Parameters : data= input data set with USUBJID (required) by= variables to count by, separated by blanks (required) where= condition on the input (default: all records) out= output data set (default: counts) debug= Y keeps the numbered work data sets and switches on MPRINT Example : %count_subj(data=refadm.adae, by=trtan, where=%STR(trtemfl = "Y")) Change log : 2026-10-31 1.0 first version 2026-10-31 1.1 checks the parameters, cleans up with %GOTO 2026-10-31 1.2 debug= keeps _cs_ data sets, MPRINT saved and restored*******************************************************************************/%MACRO count_subj(data=, by=, where=1, out=counts, debug=N); %LOCAL i v byc old_mprint;
/* Step 0: save the options and remove what an earlier call left */ %LET old_mprint = %SYSFUNC(GETOPTION(MPRINT)); %IF %UPCASE(&debug.) = Y %THEN %DO; OPTIONS MPRINT; %END; PROC DATASETS LIBRARY=work NOLIST NOWARN; DELETE _cs_:; RUN; QUIT;
/* Check the parameters */ %IF %LENGTH(&data.) = 0 OR %LENGTH(&by.) = 0 %THEN %DO; %PUT %STR(ERR)OR: [COUNT_SUBJ] DATA= and BY= are required.; %GOTO finish; %END; %IF NOT %SYSFUNC(EXIST(&data.)) %THEN %DO; %PUT %STR(ERR)OR: [COUNT_SUBJ] Data set &data. does not exist.; %GOTO finish; %END; %LET byc = %SYSFUNC(TRANWRD(%SYSFUNC(COMPBL(&by.)), %STR( ), %STR(, )));
/* Step 010: the records that count */ DATA _cs_010; SET &data.; WHERE %UNQUOTE(&where.); RUN;
/* Step 020: one count per BY group */ PROC SQL; CREATE TABLE _cs_020 AS SELECT &byc., COUNT(DISTINCT usubjid) AS n_subj FROM _cs_010 GROUP BY &byc.; QUIT;
DATA &out.; SET _cs_020; RUN;
%finish: %IF %UPCASE(&debug.) NE Y %THEN %DO; PROC DATASETS LIBRARY=work NOLIST NOWARN; DELETE _cs_:; RUN; QUIT; %END; OPTIONS &old_mprint.;%MEND count_subj;Read it from top to bottom. Each part has a job:
- The header says what the macro does, every parameter, an example call, and a change log with a version for every change. A user should never need to read the code to call it.
- Step 0 saves the options it changes (
GETOPTION(MPRINT)) and deletes work data sets an earlier call left behind (DELETE _cs_:). - Parameter checks stop early with a clear message:
ERROR: [COUNT_SUBJ] .... The bracket names the macro, so a log reader knows at once where the message comes from. The word is split (%STR(ERR)OR) so that the source line itself never looks like an error. - Numbered steps with a private prefix:
_cs_010,_cs_020. The prefix keeps them apart from the caller’s data sets; the numbers show the order. debug=Ykeeps the numbered data sets and switches on MPRINT, so you can open each step and see the code it wrote.- One exit. Every path, also an error, jumps to the label
%finish:with%GOTO. There the macro cleans up and restores the options. The label must not be a reserved word of the macro language: a label%stop:givesERROR: Macro keyword STOP is not yet implemented.
19.2 Use it, and look inside with debug=Y
Section titled “19.2 Use it, and look inside with debug=Y”/* Chapter 19: two calls, one with debug=Y */%count_subj(data=refadm.adae, by=trtan, where=%STR(trtemfl = "Y"), out=teae_n)PROC PRINT DATA=teae_n NOOBS; RUN;
%count_subj(data=refadm.adsl, by=trt01pn sex, out=sex_n, debug=Y)PROC SQL; SELECT memname FROM dictionary.tables WHERE libname = "WORK" AND memname LIKE '\_CS\_%' ESCAPE '\';QUIT;What you should see: subjects with at least one TEAE:
| TRTAN | n_subj |
|---|---|
| 0 | 65 |
| 54 | 77 |
| 81 | 76 |
the same numbers as the join in Chapter 6. After the second call, with debug=Y, the work library still holds _CS_010 and _CS_020, and the log shows the code the macro wrote:
MPRINT(COUNT_SUBJ): CREATE TABLE _cs_020 AS SELECT trt01pn, sex, COUNT(DISTINCT usubjid) AS n_subj FROM _cs_010 GROUP BY trt01pn, ...MPRINT(COUNT_SUBJ): OPTIONS NOMPRINT;The last line is the macro putting MPRINT back the way it found it.
19.3 Test it like a product
Section titled “19.3 Test it like a product”A test harness runs the macro with good and bad input, captures the log, and counts the messages of each test case. A test whose name starts with T must leave no ERROR and no WARNING; a test that starts with E must give exactly one ERROR.
/* Chapter 19: a small test harness for the macro */%MACRO tc(id, desc); %PUT ===== TEST &id. &desc.;%MEND tc;
FILENAME tlog "%SYSFUNC(PATHNAME(work))/tests.log";PROC PRINTTO LOG=tlog NEW; RUN;
%tc(T01, subjects with a TEAE by treatment)%count_subj(data=refadm.adae, by=trtan, where=%STR(trtemfl = "Y"), out=t01)%tc(T02, subjects by treatment and sex)%count_subj(data=refadm.adsl, by=trt01pn sex, out=t02)%tc(E01, a data set that does not exist)%count_subj(data=work.nothere, by=trtan)%tc(E02, no DATA= parameter)%count_subj(by=trtan)
PROC PRINTTO LOG=LOG; RUN;
/* Scan the log: one record per test start and per message */DATA scan(KEEP=test kind); INFILE tlog TRUNCOVER; INPUT line $CHAR200.; LENGTH test $8 kind $7; RETAIN test; /* the message words are built from two parts, so this program's own source never matches */ IF line =: "===== TEST" THEN DO; test = SCAN(line, 3, " "); kind = "start"; END; ELSE IF line =: CATS("ERR", "OR") THEN kind = "error"; ELSE IF line =: CATS("WAR", "NING") THEN kind = "warning"; IF NOT MISSING(kind) THEN OUTPUT;RUN;
/* A test passes with no message; an E test passes with exactly one error */PROC SQL; SELECT test, SUM(kind = "error") AS errors, SUM(kind = "warning") AS warnings, CASE WHEN test LIKE 'E%' AND CALCULATED errors = 1 AND CALCULATED warnings = 0 THEN "pass" WHEN test LIKE 'T%' AND CALCULATED errors = 0 AND CALCULATED warnings = 0 THEN "pass" ELSE "FAIL" END AS result FROM scan GROUP BY test ORDER BY test;QUIT;What you should see:
| test | errors | warnings | result |
|---|---|---|---|
| E01 | 1 | 0 | pass |
| E02 | 1 | 0 | pass |
| T01 | 0 | 0 | pass |
| T02 | 0 | 0 | pass |
Run the harness after every change to the macro. When a change breaks a case, you see it before your colleagues do.
19.4 An autocall library
Section titled “19.4 An autocall library”You do not want %INCLUDE lines for twenty macros in every program. An autocall library is a folder: one file per macro, the file named like the macro. SAS finds and compiles the macro the first time you call it.
/* Chapter 19: an autocall library finds a macro by its file name */DATA _NULL_; rc = DCREATE("mymacros", "%SYSFUNC(PATHNAME(work))");RUN;
DATA _NULL_; FILE "%SYSFUNC(PATHNAME(work))/mymacros/hello_lib.sas"; PUT '%MACRO hello_lib(name=world);'; PUT ' %PUT NOTE: hello &name. from the autocall library;'; PUT '%MEND hello_lib;';RUN;
OPTIONS MAUTOSOURCE SASAUTOS=("%SYSFUNC(PATHNAME(work))/mymacros" SASAUTOS);%hello_lib(name=reader)What you should see: NOTE: hello reader from the autocall library. No %INCLUDE: SAS found hello_lib.sas in the folder named in SASAUTOS. Keep SASAUTOS at the end of the list so the SAS-supplied macros stay available.
19.5 Debug it in DataStep Studio
Section titled “19.5 Debug it in DataStep Studio”Write macros you want to debug with empty parentheses, %MACRO name();, because of a known call-pane issue.
Exercises
Section titled “Exercises”Exercise 19-1. Add test case E03: a BY variable that does not exist in the data. What message should the macro give? Add the check to the macro, then make the test pass.
Exercise 19-2. Add a pct=Y parameter that adds the percentage of the big N from ADSL. Write test T03 first, then the code.
Exercise 19-3. Move %count_subj into the autocall folder of 19_4_autocall.sas and call it without %INCLUDE.
Answers: Chapter 19 answers.