5
$\begingroup$

There are fairly straightforward ways to test a compiler's behaviour on valid programs: most obviously, build a suite of programs to run through the compiler, and know what should happen when they run.

For the compiler's treatment of invalid programs that it rejects statically, things are a bit harder. While producing probable parse errors isn't hard, since line noise almost certainly suffices, producing constructive test cases and measuring them isn't. Among other complexities:

  • there are many more ways for the code to be wrong than right, but not all of them are interesting;
  • a test program that's rejected for e.g. a type error instead of the intended parse error is still a wrong result;
  • there can be multiple feasible parse errors arising from one input, but it should reliably produce the "right" one;
  • and in the right place(s);
  • a compiler change that causes a different parse error to be produced is likely a problem;
  • but error messages can be expected to change over time to be more useful to the programmer in ways that the semantics of a working program won't, but updating the entire test suite when that happens is fraught;
  • both rejected and accepted inputs are important.

Producing useful test cases is complicated, evaluating them is difficult, and ensuring comprehensive coverage is challenging. A different approach is required, or a combination of approaches.

How should testing a compiler's parse error detection be managed? (How) can the compiler itself help this process?

$\endgroup$

2 Answers 2

5
$\begingroup$

I think there is actually a pretty straightforward way to achieve this, but it depends on the compiler being designed to make testing easy. Particularly, errors (and other diagnostics) must be structured data, not just strings, and the compiler needs an API which returns diagnostics in a data structure rather than directly printing them to stderr.

None of these things is particularly difficult to achieve. I'll use the compiler for my own language, Papyri, as an example:

  • Diagnostics, including syntax errors, are represented as an enum type (source).
  • Diagnostics are collected into a list during compilation (source), instead of being output immediately when they occur.
  • Then, test cases for diagnostics can consist of an input as a string, and an enum variant for which diagnostic is expected (source; these examples use a macro to make the tests easier to write).

I'll run through your bullet points to discuss how this strategy accounts for them:

there are many more ways for the code to be wrong than right, but not all of them are interesting;

A test suite for the compiler's diagnostics can be comprehensive if it covers every code path which emits a diagnostic. Having diagnostics represented by an enum makes it easier to ensure full coverage without writing excessively many tests for "uninteresting" cases which are already covered. In practice, many parse errors will only be emitted in one place; for diagnostics reported in multiple places, coverage can be ensured using the IDE's "find all occurrences" tool, or using a code coverage tool.

a test program that's rejected for e.g. a type error instead of the intended parse error is still a wrong result; there can be multiple feasible parse errors arising from one input, but it should reliably produce the "right" one; and in the right place(s);

Each test passes if and only if the expected enum variant is present in the list after compilation; occurrences of other diagnostics don't affect the test result.

In the Papyri compiler, I'm not also testing that the diagnostic is associated with the correct source span, but that's mainly because this would make test-writing more difficult, and the test cases are simple enough that there is only one place that the expected diagnostic could occur.

So if the diagnostic occurs at all then it's very likely in the right place, and I don't feel the need to also test that it does occur in the right place. If you do think it would be worth it, though, then a test could be a triple of (source, expected enum variant, expected span).

a compiler change that causes a different parse error to be produced is likely a problem; but error messages can be expected to change over time to be more useful to the programmer in ways that the semantics of a working program won't, but updating the entire test suite when that happens is fraught;

By using enum variants, the tests are stable with respect to changes in the wording of error messages, while still catching bugs where the wrong kind of diagnostic is emitted. Therefore there is no need to update the test suite when the wording of an error message changes, only when new kinds of diagnostic are introduced, or the conditions on which some kind of diagnostic should be reported are changed.

both rejected and accepted inputs are important.

Accepted inputs (i.e. those without parse errors) will be covered by all the other test cases which test for a particular result instead of a particular diagnostic being emitted. I don't find it that useful to write test cases where the input is valid and the test passes only if no diagnostics are emitted; it's more useful to also require the test to pass if the output matches some expectation.

So yes, it's important to test that parse errors aren't emitted for valid inputs, but this is something you get "for free" when you test that valid inputs have correct compilation results. Therefore I don't think it needs to be part of the strategy for testing diagnostic reporting.

$\endgroup$
2
  • $\begingroup$ I buy the rest of this, but "A test suite can be comprehensive if it covers every diagnostic that the parser or compiler is able to emit." is a bit questionable, isn't it? It proves there's a way to produce each kind of error, but not more than that. $\endgroup$
    – Michael Homer
    Jun 18, 2023 at 1:07
  • $\begingroup$ @MichaelHomer That's a fair point. I would say in practice that there are very few kinds of parse error which can be emitted in more than one code path; at least, that's the case with my own language. If you want to be more rigorous then you can use the IDE's "find all occurrences" feature for each enum variant to ensure that each code path for each enum variant is covered; or you can use a code coverage tool to find code paths that aren't being tested by your existing test suite, and that will include the paths which emit diagnostics that you aren't testing for. $\endgroup$
    – kaya3
    Jun 18, 2023 at 1:11
2
$\begingroup$

kaya3's excellent answer can be summarised as:

Decouple identification of errors and reporting &

Errors are Structured Data

Many of the same practices for good testing apply as for regular software engineering but there are a few additional considerations.

A few points I would add:

  • To make generating new test cases easier (for users) you can add a flag to your interpreter or compiler to output the structured error information directly instead of the actual error messages.

Equivalence partitioning

You want tests that cover the space of possible behaviours with little or no overlap.

Coverage Analysis

  • You can aim for near 100% line coverage of the code
  • A much lower branch coverage is reasonable.

For a programming language you need to consider grammatical coverage. E.g. Syntax Based Parsing

Fuzzing

The next level of reliability up good coverage is via fuzzing. Randomly mutate your programs to produce new and interesting errors. (Bonus point if you can use the same mechanism for genetic programming)

Grammar Fuzzing

Combining grammatical coverage and fuzzing

Formal Verification

The next level up is formal verfication or Compiler correctness. A few programming languages or subsets thereof have achieved this.

$\endgroup$

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .