Clean Code, Testing & Best Practices
In short: Code that is easy to read, change and test.
- My practices: meaningful names, small single-purpose methods, guard clauses instead of deep nesting, no magic numbers, consistent formatting enforced by
.editorconfig/dotnet format, comments for why, unit tests for business logic, small PRs and code review, and the Boy Scout rule — leave code slightly better each time.
In short: A unit test verifies a small piece of logic (a method/class) in isolation from external dependencies (DB, network, time), which are replaced with test doubles.
- Good tests are FIRST (Fast, Isolated, Repeatable, Self-validating, Timely), follow Arrange-Act-Assert, test behaviour not implementation details, have one logical assertion focus, and have descriptive names.
[Fact] and [Theory] in xUnit?In short: [Fact] is a test with no parameters that always runs once.
[Theory]is a parameterised test that runs once per data set, supplied by[InlineData],[MemberData]or[ClassData].- Use theories to test the same rule with many inputs (boundary values).
In short: All three are .NET test frameworks.
- xUnit (most common in new .NET Core projects): new class instance per test, constructor/
IDisposablefor setup/teardown,[Fact]/[Theory], fixtures for shared state. - NUnit:
[Test],[TestCase],[SetUp]/[TearDown], rich constraint assertions. - MSTest: Microsoft's,
[TestMethod],[DataRow],[TestInitialize], well integrated into Visual Studio. - Functionally similar; teams pick by convention.
In short: Mocking replaces a real dependency with a controllable test double.
- A stub returns predefined data (state-based testing).
- A mock also verifies that expected interactions happened (
Verify(..., Times.Once)). - A fake is a working lightweight implementation (in-memory repository, fake clock).
- Use stubs/fakes by default; verify interactions only when the interaction is the behaviour (e.g. "an email was sent").
DateTime.Now, HttpClient or a database?- Time: inject
TimeProvider(.NET 8 built-in abstraction;FakeTimeProviderin tests) or your ownIClock. - HttpClient: inject a typed client and in tests provide a fake
HttpMessageHandlerreturning canned responses — or wrap the external API behind an interface and mock that. - Database: hide it behind a repository interface for unit tests; for the data layer itself, write integration tests against a real engine (SQL Server in Testcontainers, or SQLite) rather than EF's InMemory provider, which doesn't behave like a relational DB.
In short: Test-Driven Development: Red (write a failing test for the next small behaviour), Green (write the minimum code to pass), Refactor (clean up with tests protecting you).
- It produces testable, decoupled designs and high meaningful coverage.
- Honest answer: "I use TDD for business rules and bug fixes — I first write a test that reproduces the bug — and test-after for thin glue code."
In short: Many fast, cheap unit tests at the base;
- fewer integration/API tests (real DB, real HTTP pipeline via
WebApplicationFactory) in the middle; - very few slow, brittle end-to-end UI tests (Playwright) at the top.
- An inverted pyramid ("ice-cream cone") with mostly manual/UI tests is slow and flaky.
In short: Correctness and edge cases, tests, design (SOLID, right layer), readability, performance (N+1 queries, sync-over-async, unbounded loads), security (injection, authorisation, secrets, logging PII), and operability (logging, config, migrations, backward compatibility).
- I keep comments respectful and specific, separate blocking issues from nits, and prefer small PRs.
In short: Symptoms that suggest a deeper design problem: Long Method → Extract Method;
- God Class → Extract Class (SRP);
- Duplicate Code → extract shared method;
- Switch on type → polymorphism/Strategy;
- Primitive Obsession → value objects;
- Long Parameter List → parameter object;
- Feature Envy → Move Method.
In short: merge combines branches and keeps both histories with a merge commit (non-destructive).
rebasereplays your commits on top of another branch, which rewrites them for a linear history — never on shared/public branches.- Squash merge combines all PR commits into one commit on
main, keeping history clean (one commit per feature).
In short: Trunk-based: everyone integrates into main at least daily via short-lived branches; incomplete features hidden behind feature flags; enables CI/CD.
- GitFlow: long-lived
develop,release/*,hotfix/*branches; fits scheduled releases and multiple supported versions but causes merge pain. - Most modern teams use trunk-based or GitHub Flow.
In short: A tool (Stryker.NET) makes small changes ("mutants") to your code — flipping > to >=, removing a call — and reruns the tests.
- If tests still pass, the mutant "survived", meaning your tests don't really check that behaviour.
- It measures test quality, unlike line coverage.
In short: SQL injection: never concatenate user input into SQL; use EF Core LINQ, FromSql($"...") (interpolation is parameterised), Dapper parameters or SqlParameter.
- IDOR (Insecure Direct Object Reference): don't trust IDs from the URL — always check the resource belongs to the current user or tenant (
WHERE Id = @id AND UserId = @currentUser), using resource-based authorisation or global query filters for multi-tenant data.
- Understand: talk to the team, read bug history, find the riskiest, most-changed areas (Git churn).
- Add a CI pipeline with build + analyzers if missing.
- Write characterisation/integration tests around the critical flows before touching them.
- Fix bugs test-first (a regression test per bug).
- Refactor incrementally in touched areas (Boy Scout rule), not a big-bang rewrite.
- Add logging/monitoring to see failures early.
- Agree on coding standards and PR reviews.
- Measure: bug count, deployment frequency, time to fix.
In short: Accept feedback professionally — ask for specifics if unclear.
- Then extract cohesive parts into well-named private methods or separate classes, move external calls (DB, HTTP, time) behind injected interfaces so the logic is testable, add unit tests for the extracted logic, and keep the PR focused.
- Reply to each comment with what you changed.
In short: Flaky test causes: dependence on time/date or time zone, test order or shared static state, parallel tests sharing a DB/file, race conditions with async code (missing await), relying on dictionary/hash ordering, network calls to real services, culture-specific formatting.
- Fix by isolating state (fresh DB per test or transactions/Respawn), injecting
TimeProvider, awaiting properly, fixing culture, and removing external calls.
Object reference not set to an instance of an object with no context. How do you make this debuggable next time?In short: Enable nullable reference types and fix warnings;
- validate inputs with guard clauses (
ArgumentNullException.ThrowIfNull); - use structured logging with context (OrderId, UserId, CorrelationId);
- a global exception handler that logs the full exception with request path and trace ID;
- OpenTelemetry/Application Insights for distributed traces;
- publish symbols (portable PDBs) so stack traces have line numbers.