Guides Guide 4 Process

Testing Your Own Code

The interviewer is deciding one thing: would they merge your code without checking it? Finding your own bug, out loud, answers that better than writing perfect code by luck.

Most candidates write the code, look at it, and say “I think that works”. That sentence hands the job of checking to the interviewer, and they notice. This page is a repeatable way to check code you cannot run, and a quick way to test it in C# when you can.

Contents

  1. The dry run
  2. The five boundary checks
  3. Edge cases by input type
  4. Hunting off-by-one errors
  5. A worked bug hunt
  6. Writing tests when you can run code
  7. A tiny xUnit sketch
  8. The adversarial pass

The dry run

Pick the smallest input that reaches the interesting branch. Then run your code by hand, writing every variable down, one line per loop pass. Do not read the code and imagine what it does. Execute it, literally, including the lines you are sure about.

Four rules make this reliable rather than theatre.

A trace for a two-pointer scan looks like this. Each line records every variable that changes.

The five boundary checks

After the dry run, check these five things on purpose. They account for most interview bugs.

  1. Before the first pass. Are the start values right? Is best seeded at 0 when it should be int.MinValue or the first element?
  2. The first pass. Does anything read an index that does not exist yet, such as nums[i - 1] at i = 0? In C# that throws IndexOutOfRangeException.
  3. The last pass. Does anything read past the end, such as nums[i + 1] at the final index?
  4. After the loop. Is there unfinished work? A stack still holding items, a final window never recorded, a last group never flushed?
  5. The loop ends. Does every path through the body move something forward? This is where binary search hangs.
Check 4 is the one people skip. Monotonic stacks leave items behind. Sliding windows can finish without recording the last valid window. Grouping loops need a final flush. If your loop builds anything up, ask what happens to it when the input runs out.

Edge cases by input type

Do not brainstorm from scratch under pressure. Run the list for the type you were handed.

Say which ones you are skipping and why. “The problem promises at least one element, so I will not guard the empty case” scores better than silently not handling it. It shows you thought about it and made a decision.

Hunting off-by-one errors

Six questions catch nearly all of them.

The fastest off-by-one test. Run the code on an input of size one, and then on size two. Almost every boundary error shows up in one of those two, and both take twenty seconds to trace.

A worked bug hunt

Here is a real bug in a real solution, and how a dry run exposes it. The problem is Longest Substring Without Repeating Characters.

Buggy
public static class LongestUniqueBuggy
{
    /// <summary>Has a bug: left can
    /// move backwards.</summary>
    /// <param name="s">The text.</param>
    /// <returns>A window length.</returns>
    /// <example>Length("abba") returns 3,
    /// but the answer is 2.</example>
    public static int Length(string s)
    {
        var lastSeen = new Dictionary<char, int>();
        int left = 0, best = 0;   // empty window
        for (int right = 0; right < s.Length;
             right++)
        {
            char ch = s[right];
            // BUG: no check that the old
            // sighting is still in the window.
            if (lastSeen.TryGetValue(ch,
                    out int prev))
            {
                left = prev + 1;  // + 1: past it
            }
            lastSeen[ch] = right;
            // + 1: [left, right] is inclusive
            best = Math.Max(best,
                right - left + 1);
        }
        return best;
    }
}
Fixed
public static class LongestUnique
{
    /// <summary>Longest run with no repeated
    /// character. O(n).</summary>
    /// <param name="s">The text.</param>
    /// <returns>The longest window length.</returns>
    /// <example>Length("abba") returns 2.
    /// </example>
    public static int Length(string s)
    {
        var lastSeen = new Dictionary<char, int>();
        int left = 0, best = 0;   // empty window
        for (int right = 0; right < s.Length;
             right++)
        {
            char ch = s[right];
            // Only a repeat INSIDE the window
            // can push the left edge in.
            if (lastSeen.TryGetValue(ch,
                    out int prev) && prev >= left)
            {
                left = prev + 1;  // + 1: past it
            }
            lastSeen[ch] = right;
            // + 1: [left, right] is inclusive
            best = Math.Max(best,
                right - left + 1);
        }
        return best;
    }
}

Both pass "abcabcbb", "bbbbb" and "pwwkew", the three examples the problem gives. The bug needs a character that repeats after it has fallen out of the window. The shortest such input is "abba". Trace it:

  1. right = 0, ch = a. No earlier a. Both versions keep left = 0.
  2. right = 1, ch = b. No earlier b. left = 0, and best becomes 2.
  3. right = 2, ch = b. lastSeen['b'] = 1. A real repeat, so both move left to 2.
  4. right = 3, ch = a. lastSeen['a'] = 0, which is behind left. The buggy version sets left = 1, moving it backwards. The fixed version keeps left = 2.

At the last step the buggy version computes a width of 3 - 1 + 1 = 3 and returns 3. The correct answer is 2.

Three lessons from this one bug. First, the given examples are not a test suite. They are the happy path. Second, check that a variable which should only grow, such as left, never moves backwards. left = Math.Max(left, prev + 1) is a defensive way to write it. Third, the shortest breaking input had four characters. Small, hostile inputs beat large random ones.

Writing tests when you can run code

Many interviews now use an editor you can run. If so, spend two minutes on a test method. It is faster than a dry run and far more convincing.

using System.Diagnostics;

public static class LongestUniqueChecks
{
    /// <summary>Table-driven tests: the given examples first, then the edges.</summary>
    /// <param name="solve">The solution under test.</param>
    /// <returns>One message per failing case. Empty means every case passed.</returns>
    /// <example>LongestUniqueChecks.Run(LongestUnique.Length) returns [].</example>
    public static List<string> Run(Func<string, int> solve)
    {
        (string Text, int Want)[] cases =
        [
            ("abcabcbb", 3),   // from the problem statement
            ("bbbbb", 1),      // from the problem statement
            ("pwwkew", 3),     // from the problem statement
            ("", 0),           // empty: no window at all
            ("a", 1),          // single character
            ("abba", 2),       // the repeat that already left the window
            ("tmmzuxt", 5),    // the same trap, one step longer
        ];

        var failures = new List<string>();
        foreach (var (text, want) in cases)
        {
            int got = solve(text);
            // Debug.Assert stops a Debug build here. Release builds remove the call.
            Debug.Assert(got == want, $"\"{text}\": got {got}, want {want}");
            if (got != want)
            {
                failures.Add($"\"{text}\": got {got}, want {want}");
            }
        }
        return failures;
    }
}
Debug.Assert vanishes in Release builds. It carries [Conditional("DEBUG")], so the compiler deletes the whole call, including its arguments. If the online editor builds in Release, your asserts silently do nothing. That is why the method above also collects failures by hand. Trace.Assert stays in every build. Throwing an exception, or printing a list of failures, works everywhere.

Habits that make this worth the two minutes:

Checking against the brute force

If you have time, compare against the slow version you described in phase 3 of the script. Small random inputs find bugs no hand-picked case will. Fix the seed so a failure can be repeated.

public static class CrossCheck
{
    /// <summary>The obvious O(n^3) answer: try every window, test it for repeats.</summary>
    /// <param name="s">The text.</param>
    /// <returns>The longest window with no repeated character.</returns>
    /// <example>CrossCheck.Brute("abba") returns 2.</example>
    public static int Brute(string s)
    {
        int best = 0;   // 0 = the empty window always qualifies
        for (int i = 0; i < s.Length; i++)          // i is the window start
        {
            for (int j = i; j < s.Length; j++)      // j is the window end, inclusive
            {
                // A window is valid when its distinct count equals its length.
                // + 1 because [i, j] is inclusive.
                int length = j - i + 1;
                if (s.Substring(i, length).Distinct().Count() == length)
                {
                    best = Math.Max(best, length);
                }
            }
        }
        return best;
    }

    /// <summary>Compares a solution with Brute on random small strings.</summary>
    /// <param name="solve">The solution under test.</param>
    /// <param name="seed">The random seed, so a failure can be repeated.</param>
    /// <param name="trials">How many random strings to try.</param>
    /// <returns>The first string where they disagree, or null if none.</returns>
    /// <example>CrossCheck.FirstMismatch(LongestUnique.Length, 1, 500) returns null.</example>
    public static string? FirstMismatch(Func<string, int> solve, int seed, int trials)
    {
        var rng = new Random(seed);
        for (int t = 0; t < trials; t++)            // t counts the trials run so far
        {
            // Lengths 0..7 and a 3-letter alphabet force many repeats.
            int length = rng.Next(0, 8);            // 8 is exclusive, so at most 7
            var chars = new char[length];
            for (int i = 0; i < length; i++)
            {
                chars[i] = "abc"[rng.Next(3)];      // 3 = the alphabet size
            }
            string text = new string(chars);
            if (solve(text) != Brute(text))
            {
                return text;
            }
        }
        return null;
    }
}
The brute force only helps when it is truly trivial to write. Here it is three lines of loops and one LINQ call. If the slow version would take ten minutes, skip it and spend the time on edge cases instead. Run against the buggy version, FirstMismatch finds a failing string within a few hundred trials.

A tiny xUnit sketch

You will not set up a test project in an interview. But a take-home, or a question like “how would you test this in production?”, wants the real shape. In .NET that is usually xUnit. Create a project with dotnet new xunit, reference your code, and run dotnet test. The block below needs the xUnit package, so it does not build in a plain console app.

using Xunit;

public class LongestUniqueTests
{
    // [Theory] runs the method once per [InlineData] row.
    [Theory]
    [InlineData("abcabcbb", 3)]   // from the problem statement
    [InlineData("", 0)]           // empty
    [InlineData("a", 1)]          // single character
    [InlineData("abba", 2)]       // the repeat that left the window
    public void ReturnsLongestWindow(string text, int want)
    {
        Assert.Equal(want, LongestUnique.Length(text));
    }

    // [Fact] is a single test with no parameters.
    [Fact]
    public void AgreesWithBruteForce()
    {
        Assert.Null(CrossCheck.FirstMismatch(LongestUnique.Length, seed: 1, trials: 500));
    }
}

The adversarial pass

Last step. Stop being the author and become the reviewer. Ask these seven questions about your own code.

Say the answers out loud, even when they are fine. “I am sorting the input in place, which changes the caller’s array. If that is a problem I would copy it first.” That sentence takes four seconds. It tells the interviewer you see the code as something other people must live with. That is most of what a senior signal is.

The five things to carry forward


← Guide 3 — The Interview Script Guide 5 — Data Processing →