Application testing
.NET Core ASP.NET Core Best Practices C# Moq NUnit TDD Visual Studio Web API

How to Unit Test File Uploaders in a .NET Core Web API

Welcome to today’s post.

In today’s post I will discuss how to develop unit tests for a file uploader API.

I will introduce how to increase the code coverage of unit tests and move from the use of Mocking to testing class instances.

With .NET Core the use of dependency injection has enabled more dependencies within classes to be decoupled and increase the use of code coverage during unit testing.

By choosing to Mock dependencies instead of decoupling these and enabling them to be tested as concrete instantiations, this limits the coverage of unit tests.

In the scenario I provide, I will show how to move from Mocked unit tests to concrete unit tests that do not have to test environmental dependencies.

In a file upload API, we have two key methods, the file upload, and the file download.

With the file uploader the input consists of multiple files, and the output is a response that shows success or failed upload.

With a file downloader the input consists of the file identifier, and the output is a response that shows success or failure, with an instance of the file returned as part of a successful download.

In this post I will only discuss the upload method as it is the more logically complex of the two methods.

Before we discuss unit testing of the upload method, we can write a unit test using mocks:

A Unit Test Using Mocks

Below is a unit test using the mocking library, Moq to test the inputs, expected outputs and responses that we would expect for an upload of a file to a mock service.  

[Test]
public void TestUploadFileSuccess()
{
    string uploadFileName = "Test File 1.txt";

    var mockFileManagerService = new Mock<MockFileUtilityService>();

    // setup properties
    mockFileManagerService.SetupAllProperties();
    mockFileManagerService.SetupProperty(f => f.ErrorMessage, "OK");
    mockFileManagerService.SetupProperty(f => f.NumberUploadedFiles, 0);

    // setup file upload
    var testFormFiles = new List<string>();

    testFormFiles.Add(uploadFileName);

    FileUploadResponse resp = new FileUploadResponse();
    List<FileUploadResponseData> resplist = new List<FileUploadResponseData>(); 
    resplist.Add(new FileUploadResponseData()
    {
        Id = 1,
        Status = "OK",
        FileName = uploadFileName,
        ErrorMessage = "",
    });

    mockFileManagerService.Setup(p => p.UploadFiles(testFormFiles))
        .Callback(() =>
        {
            mockFileManagerService.Object.NumberUploadedFiles++;
            mockFileManagerService.Object.ErrorMessage = "OK";
        }
    );
    mockFileManagerService.Object.UploadFiles(testFormFiles);
    Assert.AreEqual(1, mockFileManagerService.Object.NumberUploadedFiles);
    Assert.AreEqual("OK", mockFileManagerService.Object.ErrorMessage);
}

As you can see, the use of mocking establishes what method(s) we are testing as well as any properties and outputs. What mocking does not do is to test the logic within our method.

To proceed to the state where we can test the application logic within the unit test method, will require us to refactor those sections of business logic then include the refactored classes containing the essential logic within the unit test.  

The Web API Upload Method

Here we have an upload method we wish to test:

public async Task<FileUploadResponse> UploadFiles(List<IFormFile> files)
{
    List<FileUploadResponseData> uploadedFiles = new List<FileUploadResponseData>();

    this._numberFilesUploaded = 0;
    this._errorMessage = "";

    try
    {
        foreach (var f in files)
        {
            string name = f.FileName.Replace(@"\\\\", @"\\");

            if (f.Length > 0)
            {
                var memoryStream = new MemoryStream();

                // Check file name is valid
                if (f.FileName.Length == 0)
                {
                    uploadedFiles.Add(new FileUploadResponseData()
                    {
                        Id = 0,
                        Status = "ERROR",
                        FileName = Path.GetFileName(name),
                        ErrorMessage = "File " + f 
                + " failed to upload. File name is invalid."
                    });
                    this._errorMessage = "ERROR";
                    break;
                }

                try
                {
                    await f.CopyToAsync(memoryStream);

                    // Upload check if the file is less than 2mb!
                    if (memoryStream.Length < 2097152)
                    {
                        var file = new FileUploadInfo()
                        {
                            FileName = Path.GetFileName(name),
                            FileSize = memoryStream.Length,
                            UploadDate = DateTime.Now,
                            UploadedBy = "Admin",
                            FileContent = memoryStream.ToArray()
                        };

                        _db.UploadedFiles.Add(file);

                        await _db.SaveChangesAsync();

                        uploadedFiles.Add(new FileUploadResponseData()
                        {
                            Id = file.Id,
                            Status = "OK",
                            FileName = Path.GetFileName(name),
                            ErrorMessage = "",
                        });

                        this._numberFilesUploaded++;
                        if (this._errorMessage != "ERROR")
                            this._errorMessage = "OK";
                    }
                    else
                    {
                        uploadedFiles.Add(new FileUploadResponseData()
                        {
                            Id = 0,
                            Status = "ERROR",
                            FileName = Path.GetFileName(name),
                            ErrorMessage = "File " + f 
                        + " failed to upload. File sized exceeds limit."
                        });
                   
                        this._errorMessage = "ERROR";
                    }
                }
                finally
                {
                    memoryStream.Close();
                    memoryStream.Dispose();
                }
            }
        }
        return new FileUploadResponse() { Data = uploadedFiles, ErrorMessage = "" };
    }
    catch (Exception ex)
    {
        return new FileUploadResponse() { ErrorMessage = ex.Message.ToString() };
    }
}

Notice that this consists of three key environmental dependencies:

  1. File streams.
  2. Backend databases.
  3. Memory streams.

Developing our unit tests and achieving test coverage outside of each of the above environmental dependencies will be our goal. Once we include the environmental dependencies in our unit tests, we are effectively running integration tests, which is what we want to avoid with unit tests.

Including IFormFile in the Unit Test

What are our options when moving from mocking to testing the actual class implementation without running an integration test?

  • With databases we can use an in-memory provider.
  • With file streams we can subclass interfaces and expose properties we wish to test.
  • With memory streams we can default the operations.

The testing of logical constructs within our method requires us to run our method under our test harness, which is our NUnit test method and parameters we pass as inputs into the method to be tested.

The parameters that we use for inputs as part of our test harness are representing our test environment under the unit test environment. Note that none of the parameters depend on physical test databases, network services or file systems.  The parametrizations we construct are constructed purely out of interfaces and providers which mimic what we would use with parameters dependent on the external environmental.

The upload method signature takes multiple files of type IFormFile.

public async Task<FileUploadResponse> UploadFiles(List<IFormFile> files)

To be able to use this type within a unit test without creating an actual file will require us to subclass IFormFile as shown:

namespace File.Uploader.API.Services.Mocks
{
    public class FormFile: IFormFile
    {
        public string ContentType => throw new NotImplementedException();

        public string ContentDisposition => throw new NotImplementedException();

        public IHeaderDictionary Headers => throw new NotImplementedException();

        public long Length { get; set; }

        public string Name { get; }

        public string FileName { get; set; }

        public void CopyTo(Stream target)
        {
            throw new NotImplementedException();
        }

        public Task CopyToAsync(Stream target, 
            CancellationToken cancellationToken = default(CancellationToken))
        {
            return Task.FromResult(1);
        }

        public Stream OpenReadStream()
        {
            throw new NotImplementedException();
        }
    }
}

All we have done here is to implement from IFormFile and make the following changes:

  • Enable the setters with the FileName and Length properties.
  • Return a default value from the CopyToAsync() method.

The remaining properties and methods are unchanged from IFormFile as they are not used within the test method.

Let us have a look at a unit test to test a successful file upload.

[Test]
public void TestUploadFileSuccess()
{
    string uploadFileName = "Test File 1.txt";

    var fileManagerService = new FileUtilityService(context);

    FileUploadResponse resp = new FileUploadResponse();
    List<FileUploadResponseData> resplist = new List<FileUploadResponseData>();
    resplist.Add(new FileUploadResponseData()
    {
        Id = 1,
        Status = "OK",
        FileName = uploadFileName,
        ErrorMessage = "",
    });

    List<IFormFile> testFormFiles = new List<IFormFile>();
    IFormFile formFile = new FormFile() { FileName = uploadFileName, Length = 1 };
    testFormFiles.Add(formFile);
            
    var uploadResp = fileManagerService.UploadFiles(testFormFiles).GetAwaiter();
    var uploadRslt = uploadResp.GetResult();
    Assert.AreEqual(1, uploadRslt.Data.Count());
    Assert.AreEqual("OK", fileManagerService.ErrorMessage);
}

Including In-Memory Data in the Unit Test

In our unit test, the concrete code is tested without using the backend database. We are in fact using the built-in EF Core In-Memory database.

Setting up the in-memory database in our test Setup is done using the DbContext service:

services = new ServiceCollection();

services.AddDbContext<ApplicationDbContext>(options =>
    options.UseInMemoryDatabase("In_Memory_Db"));

    serviceProvider = services.BuildServiceProvider();
    context = serviceProvider.GetRequiredService<ApplicationDbContext>();

Once we have this in place the method can be executed without affecting our backend database dependencies as the only impact are in-memory schemas.

The multi-part form parameters can be reconstituted to work with our method signature by using the subclassed IFormFile interface (which is part of the Microsoft.AspNetCore.Http namespace):

List<IFormFile> testFormFiles = new List<IFormFile>();

IFormFile formFile = new FormFile() { FileName = uploadFileName, Length = 1 };
testFormFiles.Add(formFile);

The other change we make to our method is to include status properties to our uploader class to track the number of uploads and error messages after the upload method is called:

public int NumberFilesUploaded {
    get {
        return this._numberFilesUploaded;
    }
}

public string ErrorMessage {
    get {
        return this._errorMessage;
    }
}

When the above method is used without using the backend database or any of the actual file implementation, the unit tests when run, will provide higher code coverage, and will give us more confidence that the code is logically robust after changes to the codebase.

That is all for today’s post.

I hope you found this post useful and informative.

Social media & sharing icons powered by UltimatelySocial