Skip to content
Search lessons, topics, tests…
Esc

    ↑ ↓ moveEnter openEsc close

    Module 1 · 1. Foundations, Tooling, and Your First C# Programs · Lesson 3 of 24

    Designing a Small Console Application with Methods and Tests

    Learning outcomes

    By the end of this lesson, you can separate input, calculation, and output concerns; extract deterministic methods; and identify the seam that makes a console application testable.

    You will build an invoice calculator with a thin command-line shell, a reusable CalculateNet method, and a separate console test harness. You will check exact results, invalid inputs, rounding, and an arithmetic limit without installing a test framework or a NuGet package.

    First pass: follow one calculation

    Start with Core ideas, The contract before the code, and Keep the calculation independent. Trace the 2499 and 12.5 example and identify the input, calculation and output responsibilities. You do not need to memorize the command flags or test-harness implementation on a first reading.

    Optional implementation lab. The complete three-project setup and tests remain below. Return to them when you are ready to build the lab. Before running the build commands, create every listed file, including Invoice.Tests/Program.cs from the later test-harness section. That file is required for the full lab even when you defer reading its implementation.

    Core ideas

    • Console input and output are infrastructure boundaries. Keep them thin and move calculations into methods that accept values and return values.
    • A deterministic method is easier to test because the same inputs produce the same result and it does not depend on global state, the clock, or the console.
    • Guard clauses keep invalid states from flowing deeper into the program. The shell validates for a useful user-facing error; the core also guards its contract because another caller may bypass the shell.

    The shell owns text, formatting, error messages, and process exit codes. The core owns the calculation and its valid-value contract. The tests call the core directly: they do not need to type at a terminal, scrape output, or launch the application for each assertion.

    The contract before the code

    • Supply exactly two command-line arguments: gross amount, then discount percentage. The programs never read standard input and print no prompts.
    • Input uses invariant decimal conversion with only AllowLeadingSign and AllowDecimalPoint. A leading + or - is allowed; a decimal point is a dot. Spaces, commas, grouping, currency symbols, and exponent notation are rejected in this lab.
    • Gross must be at least 0. Percentage must be from 0 through 100, inclusive. The method receives decimal values after parsing; it does not receive the original text.
    • Success writes exactly one number with two fractional digits to stdout, followed by a newline; stderr is empty and the exit code is 0.
    • Wrong argument count, failed parsing, or a range error writes one diagnostic line to stderr, leaves stdout empty, and returns 2.
    • Arithmetic overflow writes one calculation-error line to stderr, leaves stdout empty, and returns 3. Valid ranges do not guarantee that this retained arithmetic expression can evaluate every decimal input.

    TryParse can receive an explicit numeric style and culture provider, and reports whether conversion succeeded. Decimal.TryParse reference

    For all expected-output examples, a line ends with the platform newline. The characters within each line are exact. An empty stream contains no characters. Build and restore messages are separate from application output.

    Optional implementation lab: create the three projects

    Prerequisite: an already installed .NET 10 SDK with its reference packs. Start in a new folder named InvoiceLab, outside any existing project tree. Use an editor to create the folders and files below; no template command, solution file, package install, or network access is required. Keep each project in its own directory so its source files are not accidentally included by another project.

    Text
    InvoiceLab/
      NuGet.Config
      Invoice.Core/
        Invoice.Core.csproj
        InvoiceCalculator.cs
      Invoice.Cli/
        Invoice.Cli.csproj
        Program.cs
      Invoice.Tests/
        Invoice.Tests.csproj
        Program.cs

    Invoice.Core is a library. Invoice.Cli and Invoice.Tests are executables that each reference that library. Invoice.Tests is an ordinary console assertion harness, so run it as a program rather than with dotnet test. Every project targets net10.0. There is no global.json in this lab.

    NuGet.Config

    XML
    <?xml version="1.0" encoding="utf-8"?>
    <configuration>
      <packageSources>
        <clear />
      </packageSources>
    </configuration>

    The local configuration clears package sources. These projects have no PackageReference entries. If the installed SDK cannot supply the required framework/reference packs, stop and correct that prerequisite; do not solve this lab by adding an unrelated package or enabling network restore.

    Invoice.Core/Invoice.Core.csproj

    XML
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <TargetFramework>net10.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
      </PropertyGroup>
    </Project>

    Invoice.Cli/Invoice.Cli.csproj

    XML
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net10.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
      </PropertyGroup>
      <ItemGroup>
        <ProjectReference Include="../Invoice.Core/Invoice.Core.csproj" />
      </ItemGroup>
    </Project>

    Invoice.Tests/Invoice.Tests.csproj

    XML
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net10.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
      </PropertyGroup>
      <ItemGroup>
        <ProjectReference Include="../Invoice.Core/Invoice.Core.csproj" />
      </ItemGroup>
    </Project>

    Keep the calculation independent

    Save this complete file as Invoice.Core/InvoiceCalculator.cs. It preserves the original CalculateNet validation, discount expression, and rounding policy. A public static class makes the method callable from both executables; no Console operation belongs in this file.

    C#
    namespace Invoice.Core;
    
    public static class InvoiceCalculator
    {
        public static decimal CalculateNet(decimal gross, decimal discountPercent)
        {
            if (gross < 0) throw new ArgumentOutOfRangeException(nameof(gross));
            if (discountPercent is < 0 or > 100)
                throw new ArgumentOutOfRangeException(nameof(discountPercent));
    
            var discount = gross * discountPercent / 100m;
            return decimal.Round(gross - discount, 2, MidpointRounding.AwayFromZero);
        }
    }

    decimal.Round accepts a decimal-place count and a rounding strategy; this lesson selects two places and midpoint rounding away from zero. Decimal.Round reference

    For gross 2499 and percentage 12.5, the expression first computes 2499 × 12.5 = 31237.5, then divides by 100 to obtain 312.375. The unrounded net is 2186.625. The selected midpoint policy produces 2186.63. We round the net once; rounding the discount first would define a different calculation.

    The range guards and numeric capacity answer different questions. decimal.MaxValue is 79228162514264337593543950335. With percentage 0, the multiplication produces 0 and the net can remain that maximum. With percentage 100, the retained expression first multiplies the maximum by 100; this intermediate result is too large even though the mathematical final net would be zero. The method exposes OverflowException, and the shell translates it to its documented exit 3. This example does not claim that all valid-range inputs are safe, or that rearranging one expression would solve every precision and range concern.

    Write the thin console shell

    Save this complete file as Invoice.Cli/Program.cs. The shell parses and validates text, calls the method once, and formats its result explicitly. The duplicate range check is intentional: it provides stable messages at the command boundary while the method remains safe to call independently.

    C#
    using System.Globalization;
    using Invoice.Core;
    
    if (args.Length != 2)
    {
        Console.Error.WriteLine("Usage: Invoice.Cli <gross> <discount-percent>");
        return 2;
    }
    
    const NumberStyles inputStyle =
        NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint;
    
    if (!decimal.TryParse(args[0], inputStyle, CultureInfo.InvariantCulture, out var gross) ||
        !decimal.TryParse(args[1], inputStyle, CultureInfo.InvariantCulture, out var percent))
    {
        Console.Error.WriteLine("Input error: use invariant decimals without spaces or grouping.");
        return 2;
    }
    
    if (gross < 0 || percent is < 0 or > 100)
    {
        Console.Error.WriteLine("Range error: gross >= 0; discount-percent between 0 and 100.");
        return 2;
    }
    
    try
    {
        var net = InvoiceCalculator.CalculateNet(gross, percent);
        Console.WriteLine(net.ToString("F2", CultureInfo.InvariantCulture));
        return 0;
    }
    catch (OverflowException)
    {
        Console.Error.WriteLine("Calculation error: decimal overflow for these inputs.");
        return 3;
    }

    Notice that the catch covers OverflowException from this operation, not every possible exception. A programming error should not silently turn into an ordinary bad-input result. The core throws an exception for a violated method contract; the process shell returns a machine-readable exit code for its caller. These are different interfaces.

    Build once and run the exact program

    Open a terminal in InvoiceLab. Check that dotnet --version selects an installed 10.0 SDK, then run the following commands in order. Both builds must succeed before continuing. The local restore prepares SDK-generated assets without a package feed.

    Shell
    dotnet --version
    dotnet restore Invoice.Cli/Invoice.Cli.csproj --configfile NuGet.Config
    dotnet restore Invoice.Tests/Invoice.Tests.csproj --configfile NuGet.Config
    dotnet build Invoice.Cli/Invoice.Cli.csproj --configuration Release --no-restore
    dotnet build Invoice.Tests/Invoice.Tests.csproj --configuration Release --no-restore

    dotnet build compiles a project and its dependencies; --no-restore skips the implicit restore step. Build reference

    Run the already-built DLL for output comparisons. For this command, stdin is empty, expected stdout is 2186.63 followed by a newline, expected stderr is empty, and expected exit is 0.

    Shell
    dotnet Invoice.Cli/bin/Release/net10.0/Invoice.Cli.dll 2499 12.5

    The equivalent project-based invocation is shown below. The arguments after -- are supplied to the application.

    Shell
    dotnet run --project Invoice.Cli/Invoice.Cli.csproj --configuration Release --no-build --no-restore -- 2499 12.5

    dotnet run builds and launches a project by default; --no-build skips its build stage. Run reference

    To inspect a failure, capture the process result immediately. In PowerShell, run the command and then read $LASTEXITCODE; in Bash, read $? before running another command. For example, the following process invocation must write only the range-error line to stderr and return 2:

    Shell
    dotnet Invoice.Cli/bin/Release/net10.0/Invoice.Cli.dll 10 100.01
    Text
    Range error: gross >= 0; discount-percent between 0 and 100.

    Optional testing deep dive: add a standalone test harness

    Save this full file as Invoice.Tests/Program.cs. Each named case either finishes normally or throws. Run records a pass or failure, keeps going so one failure does not hide the rest, and returns a failing process exit if any assertion failed. Exception assertions check both that the expected exception occurs and, for range errors, which parameter was rejected.

    C#
    using System.Globalization;
    using Invoice.Core;
    
    if (args.Length > 1 || (args.Length == 1 && args[0] != "--prove-failure"))
    {
        Console.Error.WriteLine("Usage: Invoice.Tests [--prove-failure]");
        return 2;
    }
    
    int passed = 0;
    int failed = 0;
    
    Run("normal-discount", () => Equal(2186.63m, Net(2499m, 12.5m)));
    Run("no-discount", () => Equal(2499m, Net(2499m, 0m)));
    Run("full-discount", () => Equal(0m, Net(2499m, 100m)));
    Run("zero-gross", () => Equal(0m, Net(0m, 12.5m)));
    Run("midpoint", () => Equal(1.01m, Net(1.005m, 0m)));
    Run("below-midpoint", () => Equal(1.00m, Net(1.004m, 0m)));
    Run("above-midpoint", () => Equal(1.01m, Net(1.006m, 0m)));
    Run("negative-gross", () => OutOfRange("gross", () => Net(-0.01m, 0m)));
    Run("negative-percent", () => OutOfRange("discountPercent", () => Net(10m, -0.01m)));
    Run("percent-over-100", () => OutOfRange("discountPercent", () => Net(10m, 100.01m)));
    Run("max-with-zero-percent", () => Equal(decimal.MaxValue, Net(decimal.MaxValue, 0m)));
    Run("overflow-is-visible", () => ThrowsOverflow(() => Net(decimal.MaxValue, 100m)));
    Run("repeat-same-inputs", () => Equal(Net(2499m, 12.5m), Net(2499m, 12.5m)));
    Run("culture-independent-core", () =>
    {
        var previous = CultureInfo.CurrentCulture;
        try
        {
            CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR");
            Equal(2186.63m, Net(2499m, 12.5m));
        }
        finally
        {
            CultureInfo.CurrentCulture = previous;
        }
    });
    
    if (args.Length == 1)
        Run("proof-of-failure", () => Equal(2186.62m, Net(2499m, 12.5m)));
    
    Console.WriteLine($"{passed} passed; {failed} failed.");
    return failed == 0 ? 0 : 1;
    
    static decimal Net(decimal gross, decimal percent) =>
        InvoiceCalculator.CalculateNet(gross, percent);
    
    void Run(string name, Action test)
    {
        try
        {
            test();
            passed++;
            Console.WriteLine($"PASS {name}");
        }
        catch (Exception ex)
        {
            failed++;
            Console.Error.WriteLine($"FAIL {name}: {ex.Message}");
        }
    }
    
    static void Equal(decimal expected, decimal actual)
    {
        if (expected != actual)
            throw new InvalidOperationException(
                $"Expected {expected.ToString(CultureInfo.InvariantCulture)}; " +
                $"actual {actual.ToString(CultureInfo.InvariantCulture)}.");
    }
    
    static void OutOfRange(string parameter, Action action)
    {
        try
        {
            action();
        }
        catch (ArgumentOutOfRangeException ex) when (ex.ParamName == parameter)
        {
            return;
        }
        throw new InvalidOperationException($"Expected ArgumentOutOfRangeException for {parameter}.");
    }
    
    static void ThrowsOverflow(Action action)
    {
        try
        {
            action();
        }
        catch (OverflowException)
        {
            return;
        }
        throw new InvalidOperationException("Expected OverflowException.");
    }

    Run the harness with no arguments and empty stdin. Expected stderr is empty and expected exit is 0. This is the exact expected stdout:

    Shell
    dotnet Invoice.Tests/bin/Release/net10.0/Invoice.Tests.dll
    Text
    PASS normal-discount
    PASS no-discount
    PASS full-discount
    PASS zero-gross
    PASS midpoint
    PASS below-midpoint
    PASS above-midpoint
    PASS negative-gross
    PASS negative-percent
    PASS percent-over-100
    PASS max-with-zero-percent
    PASS overflow-is-visible
    PASS repeat-same-inputs
    PASS culture-independent-core
    14 passed; 0 failed.

    The culture case temporarily changes only the harness process culture and restores it in finally. It compares decimal values, not culture-formatted strings. The repeat case alone is weak evidence: a consistently wrong method could pass it. The fixed expected values, contract exceptions, and boundary cases supply the useful checks.

    Prove that the harness can fail

    A test runner that always exits successfully gives false confidence. The optional --prove-failure argument adds a deliberately wrong expected result without editing the core method. Run it separately:

    Shell
    dotnet Invoice.Tests/bin/Release/net10.0/Invoice.Tests.dll --prove-failure

    Expected stdout contains the same fourteen PASS lines, followed by 14 passed; 1 failed. instead of the zero-failure summary. Expected stderr contains exactly the following line; expected exit is 1. This nonzero result is intentional for this check. Do not treat this mode as the normal passing test run.

    Text
    FAIL proof-of-failure: Expected 2186.62; actual 2186.63.

    Any other harness argument, or more than one argument, produces empty stdout, stderr Usage: Invoice.Tests [--prove-failure] followed by a newline, and exit 2. Stdin is ignored in every harness mode.

    Optional boundary checks: exercise the console boundary

    The harness above tests the reusable method. The following process checks test the separate text-and-exit contract. In each case, use the built Invoice.Cli DLL, supply exactly the listed argument tokens, and close stdin empty unless stated otherwise. Every listed stdout or stderr line ends in one newline.

    • 2499 12.5 → stdout 2186.63; 2499 0 → stdout 2499.00; 2499 100 → stdout 0.00; 0 12.5 → stdout 0.00. Each has empty stderr and exit 0.
    • 1.005 0 → stdout 1.01; 1.004 0 → stdout 1.00; 1.006 0 → stdout 1.01. Each has empty stderr and exit 0. These distinguish the midpoint from its neighbors.
    • +2499 +12.5 → stdout 2186.63, empty stderr, exit 0. Leading signs are allowed even though negative values subsequently fail the range contract.
    • For -0.01 0, 10 -0.01, or 10 100.01: empty stdout; stderr Range error: gross >= 0; discount-percent between 0 and 100.; exit 2.
    • For abc 12.5, 2499 12,5, 2,499 12.5, 1e3 0, or a first argument containing a space such as " 2499": empty stdout; stderr Input error: use invariant decimals without spaces or grouping.; exit 2.
    • The same input-error result applies to an empty first argument, the literal argument $2499, or 79228162514264337593543950336 0. Quote special or empty tokens using your shell’s rules; do not let the shell expand the literal dollar sign.
    • For no arguments, one argument 2499, or three arguments 2499 12.5 extra: empty stdout; stderr Usage: Invoice.Cli <gross> <discount-percent>; exit 2.
    • 79228162514264337593543950335 0 → stdout 79228162514264337593543950335.00, empty stderr, exit 0.
    • 79228162514264337593543950335 100 → empty stdout; stderr Calculation error: decimal overflow for these inputs.; exit 3.
    • With arguments 2499 12.5 and stdin containing the two lines 999 and 100: stdout still 2186.63, stderr empty, exit 0. The shell never calls Console.ReadLine, so those lines are not inputs to the calculation.

    When automating comparisons, check stdout, stderr, and exit independently. A failure message printed with exit 0 is a broken command contract, even if a person can read it. Compare stream contents after normalizing only line endings; do not trim meaningful spaces or merge the streams.

    Practice with worked solutions

    Before reading the solutions, write down the expected result or exception for each case, identify whether it belongs to the method or console boundary, and explain which defect it would catch.

    • No discount and full discount: CalculateNet(2499m, 0m) returns 2499m; CalculateNet(2499m, 100m) returns 0m. The shell formats those as 2499.00 and 0.00. These protect the inclusive percentage endpoints.
    • Midpoint rounding: CalculateNet(1.005m, 0m) returns 1.01m. Its neighbors 1.004m and 1.006m return 1.00m and 1.01m. Checking only whole-number input would not expose a changed rounding policy.
    • Invalid percentage: CalculateNet(10m, 100.01m) throws ArgumentOutOfRangeException with ParamName discountPercent. In the shell, text arguments 10 and 100.01 instead trigger the stable range message and exit 2 before calling the core.
    • Invalid text: the token 12,5 belongs in a parsing test, not a CalculateNet test. The core already accepts decimal, so it cannot tell whether a caller originally used a comma, a dot, or no text at all.
    • Arithmetic limit: maximum gross with zero percentage returns the maximum; maximum gross with 100 percentage exposes intermediate overflow. Keeping both checks prevents a misleading claim that rejecting negatives alone handles every numeric edge.
    • Test-runner failure: changing one expected result to 2186.62 must fail. The provided --prove-failure mode supplies that check and returns 1 without changing the production method.

    Failure modes to avoid

    • Mixing Console.ReadLine calls into calculation methods.
    • Returning magic sentinel values such as -1 for invalid input.
    • Writing one large Main method that cannot be exercised without starting the process.
    • Using ambient culture for machine-facing input or output while expecting identical text on every machine.
    • Testing only the happy path, or catching exceptions without checking the final test-process exit.
    • Promising overflow-free arithmetic merely because each individual input is within its allowed range.

    Interview check

    Describe how you would turn a prototype console program into code that can later be reused by an API.

    Worked answer: keep CalculateNet and its contract in the reusable library. The console adapter converts command-line text to values and translates the result or failure to streams and exit codes. An API adapter could call the same method after applying its own request-validation and response rules. The fourteen core tests would still call the library directly. Add separate adapter tests for the API boundary; passing this console harness does not establish HTTP behavior.

    Analogy

    Everyday picture

    Imagine a service counter that rewrites a customer's request onto a standard order card, sends it to a preparation station, and presents the finished result. The preparation station can also receive a test card directly; it does not need a customer standing at the counter.

    Mapping. The counter is the command-line shell. The preparation station is CalculateNet. The test harness sends known inputs straight to that method and checks the results.

    Where it stops. Checks at the station leave the counter's text and exit-code behavior to separate tests. The contract in Notes still defines exact rounding, valid inputs and failures; the story supplies no replacement rules.

    Cheat sheet (PDF)

    csharp-console-design-testing-companion.pdf15 pages · 76 KB
    Every page, in this page.

    Practice

    Sign in to mark lessons done and keep your place in the course.Sign in