commit 3743d650067816b5cd38967be75a4ddb7cd3b035
parent ef4b33443ac48a5eba331a495ec99e2e848de174
Author: Markus Hanetzok <markus@hanetzok.net>
Date: Fri, 24 Oct 2025 21:12:44 +0200
Refactor result pattern and add tests
- Made `IsSuccess`, `Value`, and `ErrorDetail` in `TypedResult` and `Result` private for better encapsulation.
- Added `TryGetValue` and `TryGetError` methods for safe access to result values and errors.
- Updated `ErrorDetail` properties to be immutable (`init`).
- Introduced unit tests for `Result` and `TypedResult` to ensure correctness.
- Added a new test project to the solution (`Tests`).
Diffstat:
7 files changed, 321 insertions(+), 27 deletions(-)
diff --git a/SimpleResults.sln b/SimpleResults.sln
@@ -2,6 +2,8 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleResults", "SimpleResults\SimpleResults.csproj", "{070FDC4D-2370-45C3-BBFB-EE6067972EA9}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{6DE7C68A-9FAD-4C70-BC7C-D4C3EB79E65F}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -12,5 +14,9 @@ Global
{070FDC4D-2370-45C3-BBFB-EE6067972EA9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{070FDC4D-2370-45C3-BBFB-EE6067972EA9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{070FDC4D-2370-45C3-BBFB-EE6067972EA9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6DE7C68A-9FAD-4C70-BC7C-D4C3EB79E65F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6DE7C68A-9FAD-4C70-BC7C-D4C3EB79E65F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6DE7C68A-9FAD-4C70-BC7C-D4C3EB79E65F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6DE7C68A-9FAD-4C70-BC7C-D4C3EB79E65F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
diff --git a/SimpleResults/Errors/ErrorDetail.cs b/SimpleResults/Errors/ErrorDetail.cs
@@ -43,7 +43,7 @@ public class ErrorDetail(
/// An integer representing a specific error type. The meaning of specific codes
/// is defined by the application using this class.
/// </value>
- public int ErrorCode { get; set; } = errorCode;
+ public int ErrorCode { get; init; } = errorCode;
/// <summary>
/// Gets or sets the human-readable error message describing what went wrong.
@@ -51,7 +51,7 @@ public class ErrorDetail(
/// <value>
/// A string containing a descriptive error message suitable for logging or displaying to users.
/// </value>
- public string Message { get; set; } = message;
+ public string Message { get; init; } = message;
/// <summary>
/// Gets or sets the exception that caused the error, if applicable.
@@ -60,5 +60,5 @@ public class ErrorDetail(
/// An <see cref="Exception"/> object if the error was caused by an exception;
/// otherwise, <c>null</c>.
/// </value>
- public Exception? Exception { get; set; } = exception;
+ public Exception? Exception { get; init; } = exception;
}
\ No newline at end of file
diff --git a/SimpleResults/Results/Result.cs b/SimpleResults/Results/Result.cs
@@ -16,6 +16,7 @@
*/
using SimpleResults.Errors;
+// ReSharper disable UnusedMember.Global
// ReSharper disable UnusedAutoPropertyAccessor.Global
namespace SimpleResults.Results;
@@ -36,7 +37,7 @@ public class Result
/// <value>
/// <c>true</c> if the operation succeeded; otherwise, <c>false</c>.
/// </value>
- public bool IsSuccess { get; set; }
+ private bool IsSuccess { get; }
/// <summary>
/// Gets a value indicating whether the operation failed.
@@ -46,7 +47,7 @@ public class Result
/// This is the inverse of <see cref="IsSuccess"/>.
/// </value>
public bool IsFailure => !IsSuccess;
-
+
/// <summary>
/// Gets or sets the error details when the operation fails.
/// </summary>
@@ -54,7 +55,7 @@ public class Result
/// An <see cref="ErrorDetail"/> object containing information about the failure,
/// or <c>null</c> if the operation succeeded.
/// </value>
- public ErrorDetail? ErrorDetail { get; set; }
+ private ErrorDetail? Error { get; }
/// <summary>
/// Initializes a new instance of the <see cref="Result"/> class representing a successful operation.
@@ -79,7 +80,7 @@ public class Result
protected Result(ErrorDetail errorDetail)
{
IsSuccess = false;
- ErrorDetail = errorDetail;
+ Error = errorDetail;
}
/// <summary>
@@ -143,4 +144,10 @@ public class Result
/// </code>
/// </example>
public static Result Failure(int code, string msg, Exception? e) => new(new ErrorDetail(code, msg, e));
+
+ public ErrorDetail TryGetError()
+ {
+ if (IsSuccess) throw new InvalidOperationException("Can't get error: Result state is success!");
+ return Error ?? throw new NullReferenceException("TypedResult.Error is null");
+ }
}
\ No newline at end of file
diff --git a/SimpleResults/Results/TypedResult.cs b/SimpleResults/Results/TypedResult.cs
@@ -16,7 +16,10 @@
*/
using SimpleResults.Errors;
+
// ReSharper disable UnusedAutoPropertyAccessor.Global
+// ReSharper disable UnusedType.Global
+// ReSharper disable UnusedMember.Global
namespace SimpleResults.Results;
@@ -28,11 +31,11 @@ namespace SimpleResults.Results;
/// Must have a parameterless constructor.</typeparam>
/// <remarks>
/// This class implements the Result pattern with a typed return value, providing a type-safe way
-/// to handle operation outcomes without throwing exceptions. It encapsulates success/failure state,
+/// to handle operation outcomes without throwing exceptions. It encapsulates a success/failure state,
/// a return value (on success), and error details (on failure).
/// Use <see cref="Result"/> when the operation does not need to return a value.
/// </remarks>
-public class TypedResult<T> where T : new()
+public class TypedResult<T>
{
/// <summary>
/// Gets or sets a value indicating whether the operation completed successfully.
@@ -40,8 +43,8 @@ public class TypedResult<T> where T : new()
/// <value>
/// <c>true</c> if the operation succeeded; otherwise, <c>false</c>.
/// </value>
- public bool IsSuccess { get; set; }
-
+ private bool IsSuccess { get; }
+
/// <summary>
/// Gets a value indicating whether the operation failed.
/// </summary>
@@ -50,7 +53,7 @@ public class TypedResult<T> where T : new()
/// This is the inverse of <see cref="IsSuccess"/>.
/// </value>
public bool IsFailure => !IsSuccess;
-
+
/// <summary>
/// Gets or sets the value returned by the successful operation.
/// </summary>
@@ -61,16 +64,16 @@ public class TypedResult<T> where T : new()
/// <remarks>
/// Always check <see cref="IsSuccess"/> before accessing this property to ensure the operation succeeded.
/// </remarks>
- public T Value { get; set; } = new();
-
+ private readonly T? _value;
+
/// <summary>
/// Gets or sets the error details when the operation fails.
/// </summary>
/// <value>
- /// An <see cref="ErrorDetail"/> object containing information about the failure,
+ /// An <see cref="_error"/> object containing information about the failure,
/// or <c>null</c> if the operation succeeded.
/// </value>
- public ErrorDetail? ErrorDetail { get; set; }
+ private readonly ErrorDetail? _error;
/// <summary>
/// Initializes a new instance of the <see cref="TypedResult{T}"/> class representing
@@ -84,22 +87,21 @@ public class TypedResult<T> where T : new()
protected TypedResult(T value)
{
IsSuccess = true;
- Value = value;
-
+ _value = value;
}
-
+
/// <summary>
/// Initializes a new instance of the <see cref="TypedResult{T}"/> class representing a failed operation.
/// </summary>
- /// <param name="errorDetail">The details of the error that caused the operation to fail.</param>
+ /// <param name="error">The details of the error that caused the operation to fail.</param>
/// <remarks>
/// This constructor is protected and should not be called directly.
/// Use the Failure factory methods instead.
/// </remarks>
- protected TypedResult(ErrorDetail errorDetail)
+ protected TypedResult(ErrorDetail error)
{
IsSuccess = false;
- ErrorDetail = errorDetail;
+ _error = error;
}
/// <summary>
@@ -107,7 +109,7 @@ public class TypedResult<T> where T : new()
/// </summary>
/// <param name="value">The value produced by the successful operation.</param>
/// <returns>A <see cref="TypedResult{T}"/> instance with <see cref="IsSuccess"/> set to <c>true</c>
- /// and <see cref="Value"/> set to the provided value.</returns>
+ /// and <see cref="_value"/> set to the provided value.</returns>
/// <example>
/// <code>
/// public TypedResult<User> GetUser(int id)
@@ -118,13 +120,13 @@ public class TypedResult<T> where T : new()
/// </code>
/// </example>
public static TypedResult<T> Success(T value) => new(value);
-
+
/// <summary>
/// Creates a new <see cref="TypedResult{T}"/> representing a failed operation with the specified error details.
/// </summary>
/// <param name="error">The error details describing why the operation failed.</param>
/// <returns>A <see cref="TypedResult{T}"/> instance with <see cref="IsSuccess"/> set to <c>false</c>
- /// and <see cref="ErrorDetail"/> populated with the provided error information.</returns>
+ /// and <see cref="_error"/> populated with the provided error information.</returns>
/// <example>
/// <code>
/// public TypedResult<User> GetUser(int id)
@@ -140,7 +142,7 @@ public class TypedResult<T> where T : new()
/// </code>
/// </example>
public static TypedResult<T> Failure(ErrorDetail error) => new(error);
-
+
/// <summary>
/// Creates a new <see cref="TypedResult{T}"/> representing a failed operation
/// with the specified error information.
@@ -149,7 +151,7 @@ public class TypedResult<T> where T : new()
/// <param name="msg">A human-readable message describing the failure.</param>
/// <param name="e">An optional exception that caused the failure. Can be <c>null</c>.</param>
/// <returns>A <see cref="TypedResult{T}"/> instance with <see cref="IsSuccess"/> set to <c>false</c>
- /// and <see cref="ErrorDetail"/> populated with the provided error information.</returns>
+ /// and <see cref="_error"/> populated with the provided error information.</returns>
/// <example>
/// <code>
/// public TypedResult<User> GetUser(int id)
@@ -167,4 +169,16 @@ public class TypedResult<T> where T : new()
/// </code>
/// </example>
public static TypedResult<T> Failure(int code, string msg, Exception? e) => new(new ErrorDetail(code, msg, e));
+
+ public T TryGetValue()
+ {
+ if (IsFailure) throw new InvalidOperationException("Can't get value: Result state is failure!");
+ return _value ?? throw new NullReferenceException("TypedResult.Value is null");
+ }
+
+ public ErrorDetail TryGetError()
+ {
+ if (IsSuccess) throw new InvalidOperationException("Can't get error: Result state is success!");
+ return _error ?? throw new NullReferenceException("TypedResult.Error is null");
+ }
}
\ No newline at end of file
diff --git a/Tests/Results/ResultTests.cs b/Tests/Results/ResultTests.cs
@@ -0,0 +1,117 @@
+using AwesomeAssertions;
+using SimpleResults.Errors;
+using SimpleResults.Results;
+
+namespace Tests.Results;
+
+public class ResultTests
+{
+ [Fact]
+ public void Success_ShouldCreateSuccessfulResult()
+ {
+ // Act
+ var result = Result.Success();
+
+ // Assert
+ result.IsFailure.Should().BeFalse();
+ }
+
+ [Fact]
+ public void Failure_WithErrorDetail_ShouldCreateFailedResult()
+ {
+ // Arrange
+ const int errorCode = 404;
+ const string message = "Not found";
+ var errorDetail = new ErrorDetail(errorCode, message);
+
+ // Act
+ var result = Result.Failure(errorDetail);
+
+ // Assert
+ result.IsFailure.Should().BeTrue();
+ var error = result.TryGetError();
+ error.ErrorCode.Should().Be(errorCode);
+ error.Message.Should().Be(message);
+ }
+
+ [Fact]
+ public void Failure_WithCodeMessageAndException_ShouldCreateFailedResult()
+ {
+ // Arrange
+ var exception = new InvalidOperationException("Test exception");
+ const int code = 500;
+ const string message = "Internal error";
+
+ // Act
+ var result = Result.Failure(code, message, exception);
+
+ // Assert
+ result.IsFailure.Should().BeTrue();
+ var error = result.TryGetError();
+ error.ErrorCode.Should().Be(code);
+ error.Message.Should().Be(message);
+ error.Exception.Should().Be(exception);
+ }
+
+ [Fact]
+ public void Failure_WithCodeMessageAndNullException_ShouldCreateFailedResult()
+ {
+ // Act
+ const int code = 400;
+ const string message = "Bad request";
+ var result = Result.Failure(code, message, null);
+
+ // Assert
+ result.IsFailure.Should().BeTrue();
+ var error = result.TryGetError();
+ error.ErrorCode.Should().Be(code);
+ error.Message.Should().Be(message);
+ error.Exception.Should().BeNull();
+ }
+
+ [Fact]
+ public void TryGetError_OnSuccessfulResult_ShouldThrowNullReferenceException()
+ {
+ // Arrange
+ var result = Result.Success();
+
+ // Act & Assert
+ var exception = Assert.Throws<InvalidOperationException>(() => result.TryGetError());
+ Assert.Contains("Can't get error", exception.Message);
+ }
+
+ [Fact]
+ public void IsFailure_OnFailedResult_ShouldReturnTrue()
+ {
+ // Arrange
+ const int code = 400;
+ const string message = "Bad request";
+ var result = Result.Failure(code, message, null);
+
+ // Act & Assert
+ Assert.True(result.IsFailure);
+ }
+
+ [Fact]
+ public void MultipleFailureCalls_ShouldCreateIndependentInstances()
+ {
+ // Arrange
+ const int code1 = 400;
+ const string message1 = "Error 1";
+ const int code2 = 500;
+ const string message2 = "Error 2";
+ var error1 = new ErrorDetail(code1, message1);
+ var error2 = new ErrorDetail(code2, message2);
+
+ // Act
+ var result1 = Result.Failure(error1);
+ var result2 = Result.Failure(error2);
+
+ // Assert
+ Assert.NotSame(result1, result2);
+ Assert.True(result1.IsFailure);
+ Assert.True(result2.IsFailure);
+ Assert.Equal(code1, result1.TryGetError().ErrorCode);
+ Assert.Equal(code2, result2.TryGetError().ErrorCode);
+ }
+}
+\ No newline at end of file
diff --git a/Tests/Results/TypedResultTests.cs b/Tests/Results/TypedResultTests.cs
@@ -0,0 +1,122 @@
+using AwesomeAssertions;
+using SimpleResults.Errors;
+using SimpleResults.Results;
+
+namespace Tests.Results;
+
+public class TypedResultTests
+{
+ [Fact]
+ public void Success_ShouldCreateSuccessfulResult()
+ {
+ // Arrange
+ const string value = "Success";
+
+ // Act
+ var result = TypedResult<string>.Success(value);
+
+ // Assert
+ result.IsFailure.Should().BeFalse();
+ result.TryGetValue().Should().Be(value);
+ }
+
+ [Fact]
+ public void Failure_WithErrorDetail_ShouldCreateFailedResult()
+ {
+ // Arrange
+ const int errorCode = 404;
+ const string message = "Not found";
+ var errorDetail = new ErrorDetail(errorCode, message);
+
+ // Act
+ var result = TypedResult<string>.Failure(errorDetail);
+
+ // Assert
+ result.IsFailure.Should().BeTrue();
+ var error = result.TryGetError();
+ error.ErrorCode.Should().Be(errorCode);
+ error.Message.Should().Be(message);
+ }
+
+ [Fact]
+ public void Failure_WithCodeMessageAndException_ShouldCreateFailedResult()
+ {
+ // Arrange
+ var exception = new InvalidOperationException("Test exception");
+ const int code = 500;
+ const string message = "Internal error";
+
+ // Act
+ var result = TypedResult<string>.Failure(code, message, exception);
+
+ // Assert
+ result.IsFailure.Should().BeTrue();
+ var error = result.TryGetError();
+ error.ErrorCode.Should().Be(code);
+ error.Message.Should().Be(message);
+ error.Exception.Should().Be(exception);
+ }
+
+ [Fact]
+ public void Failure_WithCodeMessageAndNullException_ShouldCreateFailedResult()
+ {
+ // Act
+ const int code = 400;
+ const string message = "Bad request";
+ var result = TypedResult<string>.Failure(code, message, null);
+
+ // Assert
+ result.IsFailure.Should().BeTrue();
+ var error = result.TryGetError();
+ error.ErrorCode.Should().Be(code);
+ error.Message.Should().Be(message);
+ error.Exception.Should().BeNull();
+ }
+
+ [Fact]
+ public void TryGetError_OnSuccessfulResult_ShouldThrowNullReferenceException()
+ {
+ // Arrange
+ const string value = "Success";
+ var result = TypedResult<string>.Success(value);
+
+ // Act & Assert
+ var exception = Assert.Throws<InvalidOperationException>(() => result.TryGetError());
+ Assert.Contains("Can't get error", exception.Message);
+ }
+
+ [Fact]
+ public void IsFailure_OnFailedResult_ShouldReturnTrue()
+ {
+ // Arrange
+ const int code = 400;
+ const string message = "Bad request";
+ var result = TypedResult<string>.Failure(code, message, null);
+
+ // Act & Assert
+ Assert.True(result.IsFailure);
+ }
+
+ [Fact]
+ public void MultipleFailureCalls_ShouldCreateIndependentInstances()
+ {
+ // Arrange
+ const int code1 = 400;
+ const string message1 = "Error 1";
+ const int code2 = 500;
+ const string message2 = "Error 2";
+ var error1 = new ErrorDetail(code1, message1);
+ var error2 = new ErrorDetail(code2, message2);
+
+ // Act
+ var result1 = TypedResult<string>.Failure(error1);
+ var result2 = TypedResult<string>.Failure(error2);
+
+ // Assert
+ Assert.NotSame(result1, result2);
+ Assert.True(result1.IsFailure);
+ Assert.True(result2.IsFailure);
+ Assert.Equal(code1, result1.TryGetError().ErrorCode);
+ Assert.Equal(code2, result2.TryGetError().ErrorCode);
+ }
+}
+\ No newline at end of file
diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj
@@ -0,0 +1,26 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net9.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ <IsPackable>false</IsPackable>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="AwesomeAssertions" Version="9.2.1" />
+ <PackageReference Include="coverlet.collector" Version="6.0.2"/>
+ <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0"/>
+ <PackageReference Include="xunit" Version="2.9.2"/>
+ <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"/>
+ </ItemGroup>
+
+ <ItemGroup>
+ <Using Include="Xunit"/>
+ </ItemGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\SimpleResults\SimpleResults.csproj" />
+ </ItemGroup>
+
+</Project>