2

I'm curious about a thing that I developed. I've written a custom ActionMethod, which is a custom FileResult that will export a given DataTable to a CSV file and append it to the response.

I just want to know if this is the correct way of testing:

Here's my custom ActionResult:

/// <summary>
///     Represents an ActionResult that represents a CSV file.
/// </summary>
public class CsvActionResult : FileResult
{
    #region Properties

    /// <summary>
    ///     Creates a new instance of the <see cref="CsvActionResult"/> class.
    /// </summary>
    /// <param name="data">The data which needs to be exported to a CSV file.</param>
    public CsvActionResult(DataTable data)
        : this(data, string.Format("Export_{0}.csv", DateTime.Now.ToShortTimeString()), true, Encoding.Default, ";")
    { }

    /// <summary>
    ///     Creates a new instance of the <see cref="CsvActionResult"/> class.
    /// </summary>
    /// <param name="data">The data which needs to be exported to a CSV file.</param>
    /// <param name="name">The filename of the returned file.</param>
    public CsvActionResult(DataTable data, string name)
        : this(data, name, true, Encoding.Default, ";")
    { }

    /// <summary>
    ///     Creates a new instance of the <see cref="CsvActionResult"/> class.
    /// </summary>
    /// <param name="data">The data which needs to be exported to a CSV file.</param>
    /// <param name="name">The filename of the returned file.</param>
    /// <param name="usedDelimeter">The delimeter to use as a seperator.</param>
    public CsvActionResult(DataTable data, string name, string usedDelimeter)
        : this(data, name, true, Encoding.Default, usedDelimeter)
    { }

    /// <summary>
    ///     Creates a new instance of the <see cref="CsvActionResult"/> class.
    /// </summary>
    /// <param name="data">The data which needs to be exported to a CSV file.</param>
    /// <param name="name">The filename of the returned file.</param>
    /// <param name="addRowHeaders">A boolean that indicates wether to include row headers in the CSV file or not.</param>
    public CsvActionResult(DataTable data, string name, bool addRowHeaders)
        : this(data, name, addRowHeaders, Encoding.Default, ";")
    { }

    /// <summary>
    ///     Creates a new instance of the <see cref="CsvActionResult"/> class.
    /// </summary>
    /// <param name="data">The data which needs to be exported to a CSV file.</param>
    /// <param name="name">The filename of the returned file.</param>
    /// <param name="addRowHeaders">A boolean that indicates wether to include row headers in the CSV file or not.</param>
    /// <param name="usedDelimeter">The delimeter to use as a seperator.</param>
    public CsvActionResult(DataTable data, string name, bool addRowHeaders, string usedDelimeter)
        : this(data, name, addRowHeaders, Encoding.Default, usedDelimeter)
    { }

    /// <summary>
    ///     Creates a new instance of the <see cref="CsvActionResult"/> class.
    /// </summary>
    /// <param name="data">The data which needs to be exported to a CSV file.</param>
    /// <param name="name">The filename of the returned file.</param>
    /// <param name="usedEncoding">The encoding to use.</param>
    public CsvActionResult(DataTable data, string name, Encoding usedEncoding)
        : this(data, name, true, usedEncoding, ";")
    { }

    /// <summary>
    ///     Creates a new instance of the <see cref="CsvActionResult"/> class.
    /// </summary>
    /// <param name="data">The data which needs to be exported to a CSV file.</param>
    /// <param name="name">The filename of the returned file.</param>
    /// <param name="usedEncoding">The encoding to use.</param>
    /// <param name="usedDelimeter">The delimeter to use as a seperator.</param>
    public CsvActionResult(DataTable data, string name, Encoding usedEncoding, string usedDelimeter)
        : this(data, name, true, usedEncoding, usedDelimeter)
    { }

    /// <summary>
    ///     Creates a new instance of the <see cref="CsvActionResult"/> class.
    /// </summary>
    /// <param name="data">The data which needs to be exported to a CSV file.</param>
    /// <param name="addRowHeaders">A boolean that indicates wether to include row headers in the CSV file or not.</param>
    public CsvActionResult(DataTable data, bool addRowHeaders)
        : this(data, string.Format("Export_{0}", DateTime.Now.ToShortTimeString()), addRowHeaders, Encoding.Default, ";")
    { }

    /// <summary>
    ///     Creates a new instance of the <see cref="CsvActionResult"/> class.
    /// </summary>
    /// <param name="data">The data which needs to be exported to a CSV file.</param>
    /// <param name="addRowHeaders">A boolean that indicates wether to include row headers in the CSV file or not.</param>
    /// <param name="usedDelimeter">The delimeter to use as a seperator.</param>
    public CsvActionResult(DataTable data, bool addRowHeaders, string usedDelimeter)
        : this(data, string.Format("Export_{0}", DateTime.Now.ToShortTimeString()), addRowHeaders, Encoding.Default, usedDelimeter)
    { }

    /// <summary>
    ///     Creates a new instance of the <see cref="CsvActionResult"/> class.
    /// </summary>
    /// <param name="data">The data which needs to be exported to a CSV file.</param>
    /// <param name="addRowHeaders">A boolean that indicates wether to include row headers in the CSV file or not.</param>
    /// <param name="usedEncoding">The encoding to use.</param>
    public CsvActionResult(DataTable data, bool addRowHeaders, Encoding usedEncoding)
        : this(data, string.Format("Export_{0}", DateTime.Now.ToShortTimeString()), addRowHeaders, usedEncoding, ";")
    { }

    /// <summary>
    ///     Creates a new instance of the <see cref="CsvActionResult"/> class.
    /// </summary>
    /// <param name="data">The data which needs to be exported to a CSV file.</param>
    /// <param name="addRowHeaders">A boolean that indicates wether to include row headers in the CSV file or not.</param>
    /// <param name="usedEncoding">The encoding to use.</param>
    /// <param name="usedDelimeter">The delimeter to use as a seperator.</param>
    public CsvActionResult(DataTable data, bool addRowHeaders, Encoding usedEncoding, string usedDelimeter)
        : this(data, string.Format("Export_{0}", DateTime.Now.ToShortTimeString()), addRowHeaders, usedEncoding, usedDelimeter)
    { }

    /// <summary>
    ///     Creates a new instance of the <see cref="CsvActionResult"/> class.
    /// </summary>
    /// <param name="data">The data which needs to be exported to a CSV file.</param>
    /// <param name="usedEncoding">The encoding to use.</param>
    public CsvActionResult(DataTable data, Encoding usedEncoding)
        : this(data, string.Format("Export_{0}", DateTime.Now.ToShortTimeString()), true, usedEncoding, ";")
    { }

    /// <summary>
    ///     Creates a new instance of the <see cref="CsvActionResult"/> class.
    /// </summary>
    /// <param name="data">The data which needs to be exported to a CSV file.</param>
    /// <param name="usedEncoding">The encoding to use.</param>
    /// <param name="usedDelimeter">The delimeter to use as a seperator.</param>
    public CsvActionResult(DataTable data, Encoding usedEncoding, string usedDelimeter)
        : this(data, string.Format("Export_{0}", DateTime.Now.ToShortTimeString()), true, usedEncoding, usedDelimeter)
    { }

    /// <summary>
    ///     Creates a new instance of the <see cref="CsvActionResult"/> class.
    /// </summary>
    /// <param name="data">The data which needs to be exported to a CSV file.</param>
    /// <param name="name">The filename of the returned file.</param>
    /// <param name="addRowHeaders">A boolean that indicates wether to include row headers in the CSV file or not.</param>
    /// <param name="usedEncoding">The encoding to use.</param>
    /// <param name="usedDelimeter">The delimeter to use as a seperator.</param>
    public CsvActionResult(DataTable data, string name, bool addRowHeaders, Encoding usedEncoding, string usedDelimeter)
        : base("text/csv")
    {
        this.dataTable = data;
        this.filename = name;
        this.includeRowHeader = addRowHeaders;
        this.encoding = usedEncoding;
        this.delimeter = usedDelimeter;
    }

    /// <summary>
    ///     The datatable that needs to be exported to a Csv file.
    /// </summary>
    private readonly DataTable dataTable;

    /// <summary>
    ///     The filename that the returned file should have.
    /// </summary>
    private readonly string filename;

    /// <summary>
    ///     A boolean that indicates wether to include the row header in the CSV file or not.
    /// </summary>
    private readonly bool includeRowHeader;

    /// <summary>
    ///     The encoding to use.
    /// </summary>
    private readonly Encoding encoding;

    /// <summary>
    ///     The delimeter to use as a seperator.
    /// </summary>
    private readonly string delimeter;

    #endregion Properties

    #region Methods

    /// <summary>
    ///     Start writing the file.
    /// </summary>
    /// <param name="response">The response object.</param>
    protected override void WriteFile(HttpResponseBase response)
    {
        //// Add the header and the content type required for this view.
        //response.AddHeader("Content-Disposition", string.Format("attachment; filename={0}", filename));
        //response.ContentType = base.ContentType;

        // Add the header and the content type required for this view.
        string format = string.Format("attachment; filename={0}", "somefile.csv");
        response.AddHeader("Content-Disposition", format);
        response.ContentType = "text/csv"; //if you use base.ContentType,
        //please make sure this return the "text/csv" during test execution.

        // Gets the current output stream.
        var outputStream = response.OutputStream;

        // Create a new memorystream.
        using (var memoryStream = new MemoryStream())
        {
            WriteDataTable(memoryStream);
            outputStream.Write(memoryStream.GetBuffer(), 0, (int)memoryStream.Length);
        }
    }

    #endregion Methods

    #region Helper Methods

    /// <summary>
    ///     Writes a datatable to a given stream.
    /// </summary>
    /// <param name="stream">The stream to write to.</param>
    private void WriteDataTable(Stream stream)
    {
        var streamWriter = new StreamWriter(stream, encoding);

        // Write the header only if it's indicated to write.
        if (includeRowHeader)
        { WriteHeaderLine(streamWriter); }

        // Move to the next line.
        streamWriter.WriteLine();

        WriteDataLines(streamWriter);

        streamWriter.Flush();
    }

    /// <summary>
    ///     Writes the header to a given stream.
    /// </summary>
    /// <param name="streamWriter">The stream to write to.</param>
    private void WriteHeaderLine(StreamWriter streamWriter)
    {
        foreach (DataColumn dataColumn in dataTable.Columns)
        {
            WriteValue(streamWriter, dataColumn.ColumnName);
        }
    }

    /// <summary>
    ///     Writes the data lines to a given stream.
    /// </summary>
    /// <param name="streamWriter"><The stream to write to./param>
    private void WriteDataLines(StreamWriter streamWriter)
    {
        // Loop over all the rows.
        foreach (DataRow dataRow in dataTable.Rows)
        {
            // Loop over all the colums and write the value.
            foreach (DataColumn dataColumn in dataTable.Columns)
            { WriteValue(streamWriter, dataRow[dataColumn.ColumnName].ToString()); }
            streamWriter.WriteLine();
        }
    }

    /// <summary>
    ///     Write a specific value to a given stream.
    /// </summary>
    /// <param name="writer">The stream to write to.</param>
    /// <param name="value">The value to write.</param>
    private void WriteValue(StreamWriter writer, String value)
    {
        writer.Write(value);
        writer.Write(delimeter);
    }

    #endregion Helper Methods
}

The start method of this class is WriteFile, but since this is a protected method, I've created a class in my unit test project which allows me to access this:

public class CsvActionResultTestClass : CsvActionResult
{
    public CsvActionResultTestClass(DataTable dt)
        : base(dt)
    {
    }

    public new void WriteFile(HttpResponseBase response)
    { base.WriteFile(response); }
}

Basiclly, I'm creating a class that inherits from the CsvActionResult and that allows me to execute the WriteFile method.

In my unit test itself, I do execute the following code:

    [TestMethod]
    public void CsvActionResultController_ExportToCSV_VerifyResponsePropertiesAreSetWithExpectedValues()
    {
        // Initialize the test.
        List<Person> persons = new List<Person>();

        persons.Add(new Person() { Name = "P1_Name", Firstname = "P1_Firstname", Age = 0 });
        persons.Add(new Person() { Name = "P2_Name", Firstname = "P2_Firstname" });

        // Execute the test.
        DataTable dtPersons = persons.ConvertToDatatable<Person>();

        var httpResponseBaseMock = new Mock<HttpResponseBase>();

        //This would return a fake Output stream to you SUT
        httpResponseBaseMock.Setup(x => x.OutputStream).Returns(new Mock<Stream>().Object);
        //the rest of response setup
        CsvActionResultTestClass sut = new CsvActionResultTestClass(dtPersons);

        sut.WriteFile(httpResponseBaseMock.Object);

        //sut
        httpResponseBaseMock.VerifySet(response => response.ContentType = "text/csv");
    }

This method creates a DataTable and mock the HttpResponseBase.

Then I'm calling the method WriteFile and checks the content type of the response.

Is this the correct way of testing? If there are other, better ways of testing, please tell me.

Kind regards,

tereško
  • 58,060
  • 25
  • 98
  • 150
Complexity
  • 5,682
  • 6
  • 41
  • 84

1 Answers1

0

What you doing in your Unit test and how you verify the behaviour of your SUT is correct. the technique which you have decided to use the inheritance to create a testable version is also good. This is called "Extract and Override". I would make simple changes to your test and the testable sut (system under test).

a. I would change the name to TestableCsvActionResult

 public class TestableCsvActionResult : CsvActionResult
 {
     public TestableCsvActionResult(DataTable dt)
    : base(dt)
     {
     }

     public new void WriteFile(HttpResponseBase response)
     { base.WriteFile(response); }
 }

This way it is more meaningful that you have provided a testable version. And it is not a fake.

Unit Test

    [TestMethod]
    public void CsvActionResultController_ExportToCSV_VerifyResponseContentTypeIsTextCsv()
    {
        // Arrange
        var httpResponseBaseMock = new Mock<HttpResponseBase>();
        httpResponseBaseMock.Setup(x => x.OutputStream).Returns(new Mock<Stream>().Object);
        var sut = new CsvActionResultTestClass(new DataTable());

        //Act
        sut.WriteFile(httpResponseBaseStub.Object);

        //Verify
        httpResponseBaseMock.VerifySet(response => response.ContentType = "text/csv");
    }

You test method name is good, and readable. Since you are only verifying the "text/csv" I would be more explicit on the name. This way it very clear your intention. But if you have multiple verifications, the name you had was sufficient.

ConverToDataTable is not required. Keep the test simple as possible. Use the minumum amount required to make your test pass.

Apart from general commenting (which I tidy up), everything else seems fit for the purpose.

Community
  • 1
  • 1
Spock
  • 7,009
  • 1
  • 41
  • 60