Testing private functions in .NET
2024-10-30
Keywords: .NET, F#, Testing, xUnit
As a programmer writing tests, you will inevitably encounter a scenario where you want to test a private function. However, your unit testing project will not have direct access to this private function. So, what should you do? Do you make the function public — breaking encapsulation just to test it? Or do you simply skip testing it altogether?
Ask around the C# community, and many will tell you not to test private methods — that you should focus only on public interfaces. But I think this advice is ill, and is a result of the language design. Breaking code into smaller, private functions improves readability, reduces duplication, and keeps logic organized within a single module or class. Testing these private functions directly would make it easier to ensure each part of your code behaves as expected. So, why should we ignore these methods just because the language does not lay out a clear path for us.
There are ways to do this in .NET by using Reflection or Friend assemblies. But these approaches seem like hacks to achieve somthing that there should be built-in support for in a language. Luckily we can draw some inspiration from other languages and make use of compiler directives to achieve this in a cleaner way. In Rust, the idiomatic way to create a unit test is by adding a #[cfg(test)]
attributed module at the bottom of your implementation file. Within this module, you can test public and private functions alike, all without breaking the encapsulation by having to resort to marking functions as public. Our goal is to achieve the same in a F# project.
We start by adding the following conditioned item group to our .fsproj
file.
<!-- Only include test related packages in debug build -->
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
<PackageReference Include="xunit" Version="2.9.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
These packages will only be included in a debug build, so we do not ship unneseccary packages in our production builds.
For example, let's say we have a simple Account
module. This module defines types for an account and includes a public method for retrieving the account balance. To create testable business logic, we separate it into a pure function that handles all core calculations. Then, we wrap this function in a thin, asynchronous shell to serve as the public API.
/// Represents an amount in cents.
type Cent = int
/// Represents a unique identifier for an account.
type AccountId = int
/// An insertion of money to the account.
type Insert = { Date: DateOnly; Amount: Cent }
/// A withdrawal of money from the account.
type Withdrawal = { Date: DateOnly; Amount: Cent }
/// A wire transaction on the account.
/// The sign of the amount indicates the direction.
type WireTransaction = { Date: DateOnly; Amount: Cent }
/// Represents a transaction on the account.
/// It can be an Insert, Withdrawal, or Wire transaction.
type Transaction =
| Insert of Insert
| Withdrawal of Withdrawal
| Wire of WireTransaction
/// Represents an account with a unique identifier and a list of transactions.
type Account =
{ Id: AccountId
Transactions: List<Transaction> }
/// A function that returns the account for a given AccountId asynchronously.
type GetAccount = AccountId -> Async<Account>
/// Private function to calculate the balance of an account based on its transactions.
let private calculateAccountBalance (account: Account) : Cent =
let rec calculateAccountBalanceAux (balance: Cent) (transactions: List<Transaction>) =
match transactions with
| [] -> balance
| x :: xs ->
match x with
| Insert insert -> calculateAccountBalanceAux (balance + insert.Amount) xs
| Withdrawal withdrawal -> calculateAccountBalanceAux (balance - withdrawal.Amount) xs
| Wire wireTransaction -> calculateAccountBalanceAux (balance + wireTransaction.Amount) xs
calculateAccountBalanceAux 0 account.Transactions
// Public API
/// Returns the balance of the account corresponding to the provided AccountId.
let getAccountBalance (getAccount: GetAccount) (accountId: AccountId) : Async<Cent> =
async {
let! account = accountId |> getAccount
return account |> calculateAccountBalance
}
We can now add a section at the bottom of the file to test the private function calculateAccountBalance
. To do this, we enclose the test section in a compiler directive that includes the test only when in the DEBUG configuration. The rest follows typical test-writing practices. In this case using xUnit.
Some might argue that tests belong in a separate project, not within implementation files. However, I prefer the principle that code that changes together should live together.
#if DEBUG
open Xunit
let date (y: int) (m: int) (d: int) =
DateOnly.FromDateTime(DateTime(y, m, d))
[<Fact>]
let ``Correctly sums multiple inserts`` () =
// Arrange
let account =
{ Id = 1
Transactions =
[ Insert { Date = date 2024 01 01; Amount = 100 }
Insert { Date = date 2024 03 01; Amount = 400 } ] }
// Act
let balance = calculateAccountBalance account
// Assert
Assert.Equal(500, balance)
[<Fact>]
let ``Correctly sums multiple transaction types`` () =
// Arrange
let account =
{ Id = 1
Transactions =
[ Insert { Date = date 2024 01 01; Amount = 100 }
Withdrawal { Date = date 2024 03 01; Amount = 400 } ] }
// Act
let balance = calculateAccountBalance account
// Assert
Assert.Equal(-300, balance)
[<Fact>]
let ``Correctly handles wire transaction directions`` () =
// Arrange
let account =
{ Id = 1
Transactions =
[ Wire { Date = date 2024 01 01; Amount = 200 }
Wire
{ Date = date 2024 03 01
Amount = -300 } ] }
// Act
let balance = calculateAccountBalance account
// Assert
Assert.Equal(-100, balance)
#endif
I invite you to consider this approach the next time you encounter a method or function you want to unit test, but feel hesitant to compromise your code's encapsulation. By adopting in-module testing, you can maintain the encapsulation of your modules or classes while ensuring that they are covered by tests. Before you know it, your large unit test project may have been replaced by in-module testing. Keeping related code closer together.