4
\$\begingroup\$

I'd like to be able to better test application stability. Usually when you're doing this, you run the application and keep your fingers crossed that it won't crash when an error occurs (be it a missing file, no database connection or whatever service didn't work).

The problem is that it's difficult to provoke everything that can go wrong. So instead of disabling services or removing files etc. I'd like to throw diagnostic exceptions.


The solution I came up with is based on this interface. It does not have the Enabled property because I won't inject it if it's completely disabled.

public interface IExceptionTrap
{
    void Throw
    (
        [CanBeNull] string id = null,
        [CanBeNull] Func<bool> test = null,
        [CallerFilePath] string callerFilePath = null,
        [CallerMemberName] string callerMemberName = null,
        [CallerLineNumber] int callerLineNumber = 0
    );
}

It's a small service that gets injected to whatever specifies this as a dependency.

E.g. if I wanted to simulate some repository not always working I'd do it like this:

public class AddressRepository
{
    [CanBeNull]
    public IExceptionTrap Trap { get; set; }

    public IEnumerable<string> GetAddressesWithoutZip()
    {
        Trap?.Throw();

        // do stuff...
    }
}

Internally the Throw method evaluates triggers and decides whether it's time to make a boom!. A trigger is defined as:

public interface IExceptionTrigger
{
    bool Enabled { get; }

    [CanBeNull]
    string Exception { get; }

    [CanBeNull]
    string Message { get; }

    [CanBeNull]
    string Namespace { get; }

    [CanBeNull]
    string Type { get; }

    [CanBeNull]
    string Member { get; }

    [CanBeNull]
    string Id { get; }

    bool Matches((string Namespace, string Type, string Member, string Id) trap);
}

I implemented the main interface to use a bit of reflection and a small cache to determine from where the exception is thrown. This allows the user to specify these criteria in a *.json file by setting trigger's properties to some matching values. Alternatively an Id can be used or other predicate as Func<bool>.

public class ExceptionTrap : IExceptionTrap
{
    private readonly IEnumerable<IExceptionTrigger> _triggers;

    public ExceptionTrap(IEnumerable<IExceptionTrigger> triggers)
    {
        _triggers = triggers;
    }

    // Caches trap keys because creating them requires reflection via StackTrace and we don't want to create a bottleneck.
    private readonly ConcurrentDictionary<string, (string Namespace, string Type, string Member, string Id)> _traps = new ConcurrentDictionary<string, (string, string, string, string)>();

    // Prevent inlining just in case so that the stack-trace does not change.
    [MethodImpl(MethodImplOptions.NoInlining)]
    public void Throw
    (
        string id = null,
        Func<bool> test = null,
        [CallerFilePath] string callerFilePath = null,
        [CallerMemberName] string callerMemberName = null,
        [CallerLineNumber] int callerLineNumber = 0
    )
    {
        var key = $"{callerMemberName} in {callerFilePath} at {callerLineNumber}";
        var trap = _traps.GetOrAdd(key, _ =>
        {
            // 1 - Throw
            // 2 - GetOrAdd
            var method = new StackTrace().GetFrame(2).GetMethod();

            // ReSharper disable once PossibleNullReferenceException - I doubt 'DeclaringType' is ever 'null'.
            return
            (
                method.DeclaringType.Namespace,
                method.DeclaringType.Name,
                method.Name,
                id
            );
        });

        var trigger = test is null || test() ? _triggers.FirstOrDefault(x => x.Matches(trap)) : default;
        if (!(trigger is null))
        {
            throw DynamicException.Create(trigger.Exception ?? "Diagnostic", $"This is a diagnostic exception for {trigger}. {trigger.Message}");
        }
    }
}

Triggers are are driven by ISequence<T> that I use to generate values for triggers:

public interface ISequence<out T> : IEnumerable<T>
{
}

public abstract class Sequence<T> : ISequence<T>
{
    public abstract IEnumerator<T> GetEnumerator();

    IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}

public class RegularSequence<T> : Sequence<T>
{
    private readonly T _value;

    public RegularSequence(T value) => _value = value;

    public override IEnumerator<T> GetEnumerator()
    {
        while (true) yield return _value;
        // ReSharper disable once IteratorNeverReturns - this is by design
    }
}

Triggers have a base class that implements some of the logic for checking if the trigger machtes.

public abstract class ExceptionTrigger : IExceptionTrigger, IDisposable
{
    private readonly IEnumerator<int> _sequence;

    private bool _hasElements;

    protected ExceptionTrigger(ISequence<int> sequence, int max = 0)
    {
        _sequence = (max > 0 ? sequence.Take(max) : sequence).GetEnumerator();
        _hasElements = _sequence.MoveNext();
    }

    protected int Current => _sequence.Current;

    public bool Enabled { get; set; } = true;        

    public string Exception { get; set; }

    public string Message { get; set; }

    public string Namespace { get; set; }

    public string Type { get; set; }

    public string Member { get; set; }

    public string Id { get; set; }

    public bool Matches((string Namespace, string Type, string Member, string Id) trap)
    {
        if (Enabled && TrapMatches(trap) && CanFire() && _hasElements)
        {
            _hasElements = _sequence.MoveNext();
            return true;
        }

        return false;
    }

    protected abstract bool CanFire();

    public abstract override string ToString();

    private bool TrapMatches((string Namespace, string Type, string Member, string Id) trap)
    {
        return
            Matches(Namespace, trap.Namespace) &&
            Matches(Type, trap.Type) &&
            Matches(Member, trap.Member) &&
            Matches(Id, trap.Id);
    }

    private static bool Matches(string name, string trap) => name is null || SoftString.Comparer.Equals(name, trap);

    public void Dispose() => _sequence.Dispose();
}

Other triggers work with different criteria. For testing purposes I created two of them.

[UsedImplicitly]
public class CountedTrigger : ExceptionTrigger
{
    private int _counter;

    public CountedTrigger(ISequence<int> sequence, int max = default) : base(sequence, max)
    {
    }

    protected override bool CanFire()
    {
        if (++_counter == Current)
        {
            _counter = 0;
            return true;
        }

        return false;
    }

    public override string ToString() => $"{nameof(CountedTrigger)}: {_counter}";
}

[UsedImplicitly]
public class DelayedTrigger : ExceptionTrigger
{
    private Stopwatch _stopwatch;

    public DelayedTrigger(ISequence<int> sequence, int max = default) : base(sequence, max)
    {
    }

    private TimeSpan Delay => TimeSpan.FromSeconds(Current);

    protected override bool CanFire()
    {
        _stopwatch = _stopwatch ?? Stopwatch.StartNew();
        if (_stopwatch.Elapsed >= Delay)
        {
            _stopwatch.Restart();
            return true;
        }

        return false;
    }

    public override string ToString() => $"{nameof(DelayedTrigger)}: {Delay} ({_stopwatch.Elapsed})";
}

I tested this idea with a simple test:

[TestClass]
public class ExceptionTrapTest
{
    [TestMethod]
    public void Throw()
    {
        var trap = new ExceptionTrap(new IExceptionTrigger[]
        {
            new CountedTrigger(new RegularSequence<int>(2))
        });

        var exceptionCount = 0;
        for (var i = 1; i < 10; i++)
        {
            try
            {
                trap.Throw();
            }
            catch (DynamicException ex)
            {
                Assert.IsTrue(i % 2 == 0);
                exceptionCount++;
            }
        }

        Assert.AreEqual(4, exceptionCount);
    }
}

I tried to optimize it for performance by caching trap keys in a dictionary to avoid unnecessary reflection in subsequent calls to Throw and I also prevent evaluating triggers if general criteria is not met. The first matching trigger is used to throw an exception.

Would you improve it in any way?


The code in quesiton uses two of my favourite services: DynamicException (a runtime exception compiler) and SoftString (a trimmed and case-insensitive string).

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.