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.
InvoiceLab/
NuGet.Config
Invoice.Core/
Invoice.Core.csproj
InvoiceCalculator.cs
Invoice.Cli/
Invoice.Cli.csproj
Program.cs
Invoice.Tests/
Invoice.Tests.csproj
Program.csInvoice.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 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
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>Invoice.Cli/Invoice.Cli.csproj
<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
<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.
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.
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.
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.
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.
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:
dotnet Invoice.Cli/bin/Release/net10.0/Invoice.Cli.dll 10 100.01
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.
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:
dotnet Invoice.Tests/bin/Release/net10.0/Invoice.Tests.dll
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:
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.
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
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.