Refactor error handling with UserCanceledError#16
Conversation
- Replaced string-based "user canceled" errors with a strongly-typed `UserCanceledError` class for improved type safety and readability. - Updated `SaveBytesToFileAsync` and `SelectImportFileAsync` methods to use the new error type, changing the return type of `SaveBytesToFileAsync` to `Task<Result>`. - Modified `FileServiceTests` to validate `UserCanceledError` and ensure proper error handling. - Updated `SleepTable.razor.cs` to use `HasError<UserCanceledError>()` for type-safe error checks. - Introduced the `UserCanceledError` class in `RoyAppMaui.Models` and improved overall code maintainability, readability, and test coverage.
Codecov Report✅ All modified and coverable lines are covered by tests.
🚀 New features to boost your workflow:
|
bechtd
left a comment
There was a problem hiding this comment.
FluentResults is a beautiful library. Read its docs. They are good. I know there are features that I don't know about yet.
| A.CallTo(() => fileSaver.SaveAsync(A<string>._, A<Stream>._, A<CancellationToken>._)).Returns(new FileSaverResult(null, exception)); | ||
|
|
||
| string? returnedFilePathAsNullSignalsUserCanceled = null; | ||
| A.CallTo(() => fileSaver.SaveAsync(A<string>._, A<Stream>._, A<CancellationToken>._)) | ||
| .Returns(new FileSaverResult(returnedFilePathAsNullSignalsUserCanceled, exception)); |
There was a problem hiding this comment.
it's important, in the test, to not "bury the lead".
- I like the wrap
A.CallTo....Returnsfor the "Cause -> Effect" layout of it - even though it is overly verbose, this is a test, and I want it very clear that the
nullcoming back from thisSaveAsynccall, for thepathis what signals the User Canceled
| Assert.Equal("user canceled", actual.Errors[0].Message); | ||
| Assert.Single(actual.Errors); | ||
| Assert.IsType<UserCanceledError>(actual.Errors[0]); |
There was a problem hiding this comment.
I made a UserCanceledError class, derived off of FluentResults.Error.
This specific error stood out more than others because you check for it being the cause, at least twice, and that can be handled a lot cleaner during the error check, as you will see.
| if (result.IsFailed) | ||
| { | ||
| if (!result.Errors[0].Message.Contains("user canceled")) | ||
| if (!result.HasError<UserCanceledError>()) |
There was a problem hiding this comment.
This is part of FluentResults built-in helper functions. It makes it a lot clearer AND SAFER to check for a Type (the Error class is a type) rather than a string flag. Inside the UserCanceledError, the error message is still user canceled just in case you ever wanted to display that or log that, but now it's encapsulated. You don't have to rely on that string for your "signal". The class type is so much more powerful and exact.
There was a problem hiding this comment.
Love this - I (now) remember you doing this in one of the other projects.
|
|
||
| namespace RoyAppMaui.Models; | ||
|
|
||
| public class UserCanceledError() : Error("user canceled"); |
There was a problem hiding this comment.
this is the beauty of the newer primary constructor syntax, especially when extending another class.
You don't even need a class body.
You can still add one, if you felt like you wanted to add more functionality to this, but mostly, you don't.
This is the power of OOP ... leveraging types (classes are types).
| { | ||
| Result<List<Sleep>> GetSleepDataFromCsv(string filePath); | ||
| Task<Result<bool>> SaveBytesToFileAsync(byte[] buffer, string filePath); | ||
| Task<Result> SaveBytesToFileAsync(byte[] buffer, string filePath); |
There was a problem hiding this comment.
this is a pretty common "overkill" for Result pattern.
You put bool as the return type.
But, that internal type IS OPTIONAL.
Sometimes, as is the case here (a Save op.) there's really only 2 possibilities:
- It saved successfully
- Some Error comes back (a failure)
Result already handles BOTH!
Result.Ok IS SUCCESS! Hover over it. It is literally in the C# comment.
Result.Fail OR returning an Error type you've made (like with UserCanceledError) is Failure.
Unless you need some other type of data coming back, you really don't need anything redundant for Success flag.
You'll notice at the top, in the test, I deleted the line asserting the bool was true.
We already asserted the Result was Success. That's all we needed. (I saw that earlier and it made my "Spidey-sense" go off, but I couldn't see it, just yet.
There was a problem hiding this comment.
You can almost think of this like how Unix/Linux works.
When you run a command, if you get back the prompt, you can just ASSUME SUCCESS.
ONLY An Error message coming back means failure, otherwise, assume success. It's a hard thing to get used to when you aren't use to it, but Result.Ok() is sort of like this.
Ok IS ENOUGH of a signal.
Anything else would be a message from some error.
There was a problem hiding this comment.
Another example.
OK with no content is like a 204 response code in HTTP.
200 code are supposed to contain something along with the success signal (some object or value, like an Id, from an Insert).
In many ways, especially when doing Web work, Result pattern maps very nicely to HTTP Response codes.
And, to a certain extent, you can look at your other error messages this way too ... a bit.
FileNotFoundException are like a 404
You could make a NotFoundError wherein you take in an error message (which would include the fact that this was a file not found.
OR, you could be more specific and make it FileNotFoundError and only take in the name of the file, as the argument.
OR, you could do both!
class NotFoundError(string message) : Error(message);
build NotFoundError off of Results Error, and then:
class FileNotFoundError(string filePath) : NotFoundError($"The file at {filePath} was not found.");
It's up to you how sophisticated and specific and layered you want to get.
Feel it out.
I prefer to use named Errors as opposed to Result.Fail(errorMessage
Because then I can test for them by their type, instead of using the "weaker" and bulkier "string flag" (is Equal to ... Contains)
|
|
||
| return result.IsSuccessful | ||
| ? Result.Ok(true) | ||
| ? Result.Ok() |
There was a problem hiding this comment.
again, Ok() is enough. It means Success.
And yes, this is a common thing to do, until you get more comfortable with the library.
| if (result is null) | ||
| { | ||
| return Result.Fail("user canceled"); | ||
| return new UserCanceledError(); |
There was a problem hiding this comment.
Getting past the "string flag" mentality is going to take time.
I struggled with it. I'll still do it, at first. It's normal. We think in words, not "objects", not "types".
Updated `SaveBytesToFileAsync_SaveFails_ReturnsFailure` to use `OperationCanceledException` instead of a generic `Exception`. Added an assertion to `SaveBytesToFileAsync_SaveSucceeds_ReturnsSuccess` to verify that the `Errors` collection is empty, ensuring thorough validation of the success scenario.
Refactor error handling with UserCanceledError
UserCanceledErrorclass for improved type safety and readability.SaveBytesToFileAsyncandSelectImportFileAsyncmethods to use the new error type, changing the return type ofSaveBytesToFileAsynctoTask<Result>.FileServiceTeststo validateUserCanceledErrorand ensure proper error handling.SleepTable.razor.csto useHasError<UserCanceledError>()for type-safe error checks.UserCanceledErrorclass inRoyAppMaui.Modelsand improved overall code maintainability, readability, and test coverage.