Skip to content

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_count_subj.sas
/*******************************************************************************
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:

  1. 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.
  2. Step 0 saves the options it changes (GETOPTION(MPRINT)) and deletes work data sets an earlier call left behind (DELETE _cs_:).
  3. 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.
  4. 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.
  5. debug=Y keeps the numbered data sets and switches on MPRINT, so you can open each step and see the code it wrote.
  6. 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: gives ERROR: Macro keyword STOP is not yet implemented.
19_2_use.sas
/* 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:

TRTANn_subj
065
5477
8176

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.

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.

19_3_tests.sas
/* 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:

testerrorswarningsresult
E0110pass
E0210pass
T0100pass
T0200pass

Run the harness after every change to the macro. When a change breaks a case, you see it before your colleagues do.

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.

19_4_autocall.sas
/* 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.

Write macros you want to debug with empty parentheses, %MACRO name();, because of a known call-pane issue.

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.