Skip to content

Commit

Permalink
πŸ§ͺ 73 add domain unit tests (#75)
Browse files Browse the repository at this point in the history
* Updated readme

* Moved application into src directory

* Added customer unit tests

* Added product tests

* Added money unit tests

* Added Sku unit tests

* Added Order unit tests

* Build now runs tests

* Fixed warnings

* Update action permissions

* Tidied up permission check

* Added build badge
  • Loading branch information
danielmackay committed Apr 24, 2023
1 parent 97d5a6a commit e932efb
Show file tree
Hide file tree
Showing 110 changed files with 1,094 additions and 90 deletions.
15 changes: 12 additions & 3 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ on:
pull_request:
branches: [ "main" ]

permissions:
checks: write

jobs:
build:

runs-on: ubuntu-latest

steps:
Expand All @@ -22,5 +24,12 @@ jobs:
run: dotnet restore
- name: Build
run: dotnet build --no-restore -c Release -warnaserror
#- name: Test
# run: dotnet test --no-build --verbosity normal
- name: Test
run: dotnet test --no-build --configuration Release --logger "trx;LogFileName=TestResults.trx"
- name: Test Report
uses: dorny/test-reporter@v1
if: success() || failure() # run this step even if previous step failed
with:
name: tests # Name of the check run which will be created
path: "**/*.trx" # Path to test results
reporter: dotnet-trx # Format of test results
31 changes: 0 additions & 31 deletions DDD.Domain/Common/Exceptions/DomainException.cs

This file was deleted.

70 changes: 42 additions & 28 deletions DDD.sln
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DDD.Domain", "DDD.Domain\DDD.Domain.csproj", "{CBF41725-84D2-4E0F-A0F4-9D32C177A69F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DDD.Infrastructure", "DDD.Infrastructure\DDD.Infrastructure.csproj", "{B4FAA6F4-0EB7-4A6F-A11B-F5E7C00BD20C}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DDD.Application", "DDD.Application\DDD.Application.csproj", "{3B7215A4-934F-46F7-9881-08692D806067}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DDD.WebApi", "DDD.WebApi\DDD.WebApi.csproj", "{DA3B9A6A-6F30-45E7-A07C-BF30C86D5192}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{C92B8DC0-A461-4606-A40D-806712BEA9BD}"
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
Expand All @@ -27,37 +19,59 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Infrastructure", "Infrastru
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Presentation", "Presentation", "{0A2A4BE6-DC97-4770-B4D4-ED6C29407E29}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DDD.Application", "src\DDD.Application\DDD.Application.csproj", "{400DBDDC-367D-4304-935C-8D9563031D0E}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DDD.Domain", "src\DDD.Domain\DDD.Domain.csproj", "{E8075122-DF7C-40D5-87CE-67C13FE8F329}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DDD.Infrastructure", "src\DDD.Infrastructure\DDD.Infrastructure.csproj", "{431D000B-F1CC-43CB-9DCA-50F9D8E42557}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DDD.WebApi", "src\DDD.WebApi\DDD.WebApi.csproj", "{E8360F9D-E4B9-44D3-9D31-5F99ED855763}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{D72F4748-9A50-452F-850F-A792FF502060}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{33F84828-4E86-4290-A185-878A41B7D77D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DDD.Domain.UnitTests", "tests\DDD.Domain.UnitTests\DDD.Domain.UnitTests.csproj", "{8C901001-FB7F-46D7-99FC-87A1E173AC84}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{CBF41725-84D2-4E0F-A0F4-9D32C177A69F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CBF41725-84D2-4E0F-A0F4-9D32C177A69F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CBF41725-84D2-4E0F-A0F4-9D32C177A69F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CBF41725-84D2-4E0F-A0F4-9D32C177A69F}.Release|Any CPU.Build.0 = Release|Any CPU
{B4FAA6F4-0EB7-4A6F-A11B-F5E7C00BD20C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B4FAA6F4-0EB7-4A6F-A11B-F5E7C00BD20C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B4FAA6F4-0EB7-4A6F-A11B-F5E7C00BD20C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B4FAA6F4-0EB7-4A6F-A11B-F5E7C00BD20C}.Release|Any CPU.Build.0 = Release|Any CPU
{3B7215A4-934F-46F7-9881-08692D806067}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{3B7215A4-934F-46F7-9881-08692D806067}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3B7215A4-934F-46F7-9881-08692D806067}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3B7215A4-934F-46F7-9881-08692D806067}.Release|Any CPU.Build.0 = Release|Any CPU
{DA3B9A6A-6F30-45E7-A07C-BF30C86D5192}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DA3B9A6A-6F30-45E7-A07C-BF30C86D5192}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DA3B9A6A-6F30-45E7-A07C-BF30C86D5192}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DA3B9A6A-6F30-45E7-A07C-BF30C86D5192}.Release|Any CPU.Build.0 = Release|Any CPU
{400DBDDC-367D-4304-935C-8D9563031D0E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{400DBDDC-367D-4304-935C-8D9563031D0E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{400DBDDC-367D-4304-935C-8D9563031D0E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{400DBDDC-367D-4304-935C-8D9563031D0E}.Release|Any CPU.Build.0 = Release|Any CPU
{E8075122-DF7C-40D5-87CE-67C13FE8F329}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E8075122-DF7C-40D5-87CE-67C13FE8F329}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E8075122-DF7C-40D5-87CE-67C13FE8F329}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E8075122-DF7C-40D5-87CE-67C13FE8F329}.Release|Any CPU.Build.0 = Release|Any CPU
{431D000B-F1CC-43CB-9DCA-50F9D8E42557}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{431D000B-F1CC-43CB-9DCA-50F9D8E42557}.Debug|Any CPU.Build.0 = Debug|Any CPU
{431D000B-F1CC-43CB-9DCA-50F9D8E42557}.Release|Any CPU.ActiveCfg = Release|Any CPU
{431D000B-F1CC-43CB-9DCA-50F9D8E42557}.Release|Any CPU.Build.0 = Release|Any CPU
{E8360F9D-E4B9-44D3-9D31-5F99ED855763}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E8360F9D-E4B9-44D3-9D31-5F99ED855763}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E8360F9D-E4B9-44D3-9D31-5F99ED855763}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E8360F9D-E4B9-44D3-9D31-5F99ED855763}.Release|Any CPU.Build.0 = Release|Any CPU
{8C901001-FB7F-46D7-99FC-87A1E173AC84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8C901001-FB7F-46D7-99FC-87A1E173AC84}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8C901001-FB7F-46D7-99FC-87A1E173AC84}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8C901001-FB7F-46D7-99FC-87A1E173AC84}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{CBF41725-84D2-4E0F-A0F4-9D32C177A69F} = {9ED71C16-F4E0-4AA5-9F23-980CC8EABC75}
{B4FAA6F4-0EB7-4A6F-A11B-F5E7C00BD20C} = {5B99D2D5-EFFD-4746-83B8-17DD83D13C2C}
{3B7215A4-934F-46F7-9881-08692D806067} = {9ED71C16-F4E0-4AA5-9F23-980CC8EABC75}
{DA3B9A6A-6F30-45E7-A07C-BF30C86D5192} = {0A2A4BE6-DC97-4770-B4D4-ED6C29407E29}
{9ED71C16-F4E0-4AA5-9F23-980CC8EABC75} = {D72F4748-9A50-452F-850F-A792FF502060}
{5B99D2D5-EFFD-4746-83B8-17DD83D13C2C} = {D72F4748-9A50-452F-850F-A792FF502060}
{0A2A4BE6-DC97-4770-B4D4-ED6C29407E29} = {D72F4748-9A50-452F-850F-A792FF502060}
{400DBDDC-367D-4304-935C-8D9563031D0E} = {9ED71C16-F4E0-4AA5-9F23-980CC8EABC75}
{E8075122-DF7C-40D5-87CE-67C13FE8F329} = {9ED71C16-F4E0-4AA5-9F23-980CC8EABC75}
{431D000B-F1CC-43CB-9DCA-50F9D8E42557} = {5B99D2D5-EFFD-4746-83B8-17DD83D13C2C}
{E8360F9D-E4B9-44D3-9D31-5F99ED855763} = {0A2A4BE6-DC97-4770-B4D4-ED6C29407E29}
{8C901001-FB7F-46D7-99FC-87A1E173AC84} = {33F84828-4E86-4290-A185-878A41B7D77D}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {006C5F90-E802-4A04-B28D-929EFF3C84C8}
Expand Down
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# dotnet-ef-domain-driven-design

[![.NET](https://github.com/danielmackay/dotnet-ef-domain-driven-design/actions/workflows/dotnet.yml/badge.svg)](https://github.com/danielmackay/dotnet-ef-domain-driven-design/actions/workflows/dotnet.yml)

## Features

- Aggregate Roots
Expand All @@ -19,12 +21,13 @@
- Use AggregateRoots for objects that can be created directly
- All user interactions should go via AggregateRoots
- Exposed via DbContext
- Will have a table in the DB
- Will have a table in t
- Aggregates can only be pulled in their entirety from the DB. This means that all child entities that make up the aggregate root will also be pulled in

### Entities

- Use Entities for objects that are part of an aggregate root and distinguishable by ID (usually a separate table)
- Entities cannot be modified outside of their aggregate root
- Entities **cannot be modified outside** of their aggregate root
- Not exposed via DbContext
- Will have a table in the DB
- Internal factory method for creation so that can only be created via aggregate roots
Expand Down Expand Up @@ -124,6 +127,12 @@ Sometimes entities will need to leverage a service to perform a behavior. In th
- Objects must be constructed with a factory pattern so that domain events can be raised upon explicit creation, but NOT raised when EF fetches entities from the DB.
- Properties need to be passed to constructors to ensure they are in a valid state on object creation. Can't use `required init` properties as they then become unmodifiable.
- EF does not allow owned entities to be passed to constructors, so these MUST be set via factory methods.
- Can remove nullable warnings by using `null!`. This is safe to do so as we can only create an object via our factory method which we know sets these properties.

### Unit Test Naming Conventions

// Test Naming Convention: MethodName_StateUnderTest_ExpectedBehavior
// [Method/PropertyName]_Should_[ExpectedBehavior]_When_[StateUnderTest]

## Thoughts

Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,4 @@ public abstract class BaseEntity : AuditableEntity
public abstract class BaseEntity<TId> : BaseEntity
{
public required TId Id { get; init; }

//protected BaseEntity(TId id) => Id = id;
}
File renamed without changes.
52 changes: 52 additions & 0 deletions src/DDD.Domain/Common/Exceptions/DomainException.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
ο»Ώusing DDD.Domain.Common.Extensions;
using System.Runtime.CompilerServices;

namespace DDD.Domain.Common.Exceptions;

public class DomainException : Exception
{
public DomainException()
: base()
{
}

public DomainException(string message)
: base(message)
{
}

public DomainException(string message, Exception innerException)
: base(message, innerException)
{
}

public static void ThrowIf(bool condition)
{
if (condition)
throw new DomainException();
}

public static void ThrowIf(bool condition, string message)
{
if (condition)
throw new DomainException(message);
}

public static void ThrowIfEmpty(string value, [CallerArgumentExpression(nameof(value))] string? paramName = null)
{
if (value.IsEmpty())
throw new DomainException($"{paramName} cannot be empty");
}

public static void ThrowIfNegative(decimal value, [CallerArgumentExpression(nameof(value))] string? paramName = null)
{
if (value <= 0)
throw new DomainException($"{paramName} cannot be negative");
}

public static void ThrowIfNull<T>(T value, [CallerArgumentExpression(nameof(value))] string? paramName = null) where T : class
{
if (value is null)
throw new DomainException($"{paramName} cannot be null");
}
}
File renamed without changes.
11 changes: 1 addition & 10 deletions DDD.Domain/Common/Money.cs β†’ src/DDD.Domain/Common/Money.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
ο»Ώnamespace DDD.Domain.Common;

public record Money(string Currency, decimal Amount) : IValueObject//, IComparable<Money>
public record Money(string Currency, decimal Amount) : IValueObject
{
public static Money Default => new("AUD", 0);

public static Money Zero => Default;

//public int CompareTo(Money? other)
//{
// return other == null ? 1 : (int)(Amount - other.Amount);
//}

public static Money operator +(Money left, Money right) => new Money(left.Currency, left.Amount + right.Amount);

public static Money operator -(Money left, Money right) => new Money(left.Currency, left.Amount - right.Amount);
Expand All @@ -22,8 +17,4 @@ public record Money(string Currency, decimal Amount) : IValueObject//, IComparab
public static bool operator >(Money left, Money right) => left.Amount > right.Amount;

public static bool operator >=(Money left, Money right) => left.Amount >= right.Amount;

//public static Money Add(Money left, Money right) => new Money(left.Currency, left.Amount + right.Amount);

//public static Money Subtract(Money left, Money right) => new Money(left.Currency, left.Amount - right.Amount);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
ο»Ώusing DDD.Domain.Common.Extensions;

namespace DDD.Domain.Customers;
ο»Ώnamespace DDD.Domain.Customers;

public class Customer : BaseEntity<CustomerId>, IAggregateRoot
{
Expand All @@ -17,6 +15,10 @@ private Customer() { }

public static Customer Create(string email, string firstName, string lastName)
{
DomainException.ThrowIfEmpty(email);
DomainException.ThrowIfEmpty(firstName);
DomainException.ThrowIfEmpty(lastName);

var customer = new Customer()
{
Id = new CustomerId(Guid.NewGuid()),
Expand All @@ -30,12 +32,17 @@ public static Customer Create(string email, string firstName, string lastName)

public void UpdateName(string firstName, string lastName)
{
DomainException.ThrowIf(firstName.IsEmpty(), $"{nameof(firstName)} cannot be empty");
DomainException.ThrowIf(lastName.IsEmpty(), $"{nameof(lastName)} cannot be empty");
DomainException.ThrowIfEmpty(firstName);
DomainException.ThrowIfEmpty(lastName);

FirstName = firstName;
LastName = lastName;
}

public void UpdateAddress(string address) => Address = address;
public void UpdateAddress(string address)
{
DomainException.ThrowIfEmpty(address);

Address = address;
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ private LineItem() { }
// Internal so that only the Order can create a LineItem
internal static LineItem Create(OrderId orderId, ProductId productId, Money price, int quantity)
{
DomainException.ThrowIf(price <= Money.Zero, "Cant add free products");
DomainException.ThrowIf(quantity <= 0, "Quantity can't be negative");
DomainException.ThrowIfNegative(price.Amount);
DomainException.ThrowIfNegative(quantity);

var lineItem = new LineItem()
{
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,11 @@ public void AddPayment(Money payment)
DomainException.ThrowIf(payment.Amount <= 0, "Payments can't be negative");
DomainException.ThrowIf(payment > OrderTotal - AmountPaid, "Payment can't exceed order total");

AmountPaid += payment;
// Ensure currency is set on first payment
if (AmountPaid.Amount == 0)
AmountPaid = payment;
else
AmountPaid += payment;

if (AmountPaid >= OrderTotal)
{
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
ο»Ώusing DDD.Domain.Common.Extensions;

namespace DDD.Domain.Products;
ο»Ώnamespace DDD.Domain.Products;

public class Product : BaseEntity<ProductId>, IAggregateRoot
{
Expand All @@ -15,6 +13,11 @@ private Product() { }
// NOTE: Need to use a factory, as EF does not let owned entities (i.e Money & Sku) be passed via the constructor
public static Product Create(string name, Money price, Sku sku)
{
DomainException.ThrowIfEmpty(name);
DomainException.ThrowIfNull(sku);
DomainException.ThrowIfNull(price);
DomainException.ThrowIfNegative(price.Amount);

var product = new Product
{
Id = new ProductId(Guid.NewGuid()),
Expand All @@ -30,18 +33,20 @@ public static Product Create(string name, Money price, Sku sku)

public void UpdateName(string name)
{
DomainException.ThrowIf(name.IsEmpty(), "Name cannot be empty");
DomainException.ThrowIfEmpty(name);
Name = name;
}

public void UpdatePrice(Money price)
{
DomainException.ThrowIf(price.Amount <= 0, "Price must be positive");
DomainException.ThrowIfNull(price);
DomainException.ThrowIfNegative(price.Amount);
Price = price;
}

public void UpdateSku(Sku sku)
{
DomainException.ThrowIfNull(sku);
Sku = sku;
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loading

0 comments on commit e932efb

Please sign in to comment.