DEV Community

Cover image for Solution to Challenge 3 - Visual Testing with API Mocking
abigail armijo
abigail armijo

Posted on

Solution to Challenge 3 - Visual Testing with API Mocking

Challenge #3 is done — here's my solution to Challenge 3: Visual Testing with API Mocking

I'm not a big fan of mock APIs for end-to-end tests, but when testing a dashboard with daily API changes, you need to ensure the charts are displayed correctly. One time, after some charts, one chart wasn't displayed. In another Google Maps integration, with some changes, one button overlaps another button, so I think visual testing is good for detecting those issues automatically. One approach is to mock the API to return the same data and save an image snapshot to compare the current website against it. If the UI changes, you need to update the base snapshot.

Different paid tools include visual testing tools like Percy by BrowserStack, Applitools Eyes, and Sauce Labs (Sauce Visual).

I previously wrote an article: Visual Testing with Playwright

The charts have patterns to be accessible, allowing people with low vision to see the charts without relying on colors. Accessibility testing will be the next challenge.

Playwright Visual Testing

Playwright includes a built-in free option to compare screenshots

await expect(page).toHaveScreenshot()

I created a visual helper to store snapshots with 2 methods: one for the whole page and another for a specific element. You can set the timeout to check the screenshot and the difference in pixels or as a percentage, as allowed. By default, I set a very minor difference only to check any change in the design.

import { expect, Locator, Page, test } from '@playwright/test';

export class VisualHelper {

    constructor(private page: Page) {

    }

    /**
     * Check full page snapshot
     * @param snapshotName Snapshot name
     * @param timeout Max timeout
     * @param maxDiffPixels Max number of differing pixels allowed
     */
    async checkPageSnapshot(snapshotName: string, timeout = 5_000, maxDiffPixels = 100) {
        const stepDescription = 'Compare snapshot: ' + snapshotName + ' with maxDiffPixels: ' + maxDiffPixels;
        await test.step(stepDescription, async () => {
            await expect(this.page).toHaveScreenshot(snapshotName, {
                timeout: timeout,
                maxDiffPixels: maxDiffPixels
            });
        });
    }

    /**
     * Check element snapshot
     * @param element Element to check the snapshot
     * @param snapshotName Name of the snapshot
     * @param timeout Max timeout
     * @param maxDiffPixels Max number of differing pixels allowed
     */
    async checkElementSnapshot(element: Locator, snapshotName: string, timeout = 5_000, maxDiffPixels = 100) {
        const stepDescription = 'Compare snapshot: ' + snapshotName + ' with maxDiffPixels: ' + maxDiffPixels;
        await test.step(stepDescription, async () => {
            await expect(element).toHaveScreenshot(snapshotName, {
                timeout: timeout,
                maxDiffPixels: maxDiffPixels
            });
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

To mock the API, I created an API with fixed data, for example:

{
    "Amount": 56046.00,
    "AverageDaysPastDue": 35
}
Enter fullscreen mode Exit fullscreen mode

To mock the API, I added this code:

/**
* Mock an api
* @param description Description for the HTML reporter
* @param url API URL to mock
* @param jsonData JSON that will be returned
* @param status HTTP status code to return (defaults to 200)
*/
async mockApi(description: string, url: string, jsonData: any, status = 200) {
    await test.step(description, async () => {
        await this.page.route(`**${url}`, async route => {
            await route.fulfill({
                status: status,
                contentType: 'application/json',
                body: JSON.stringify(jsonData),
            });
        });
    });
}
Enter fullscreen mode Exit fullscreen mode

To mock the summary, I created this function on the DashboardPage.ts

async mockSummary() {
    const stepDescription = 'Modify the summary with fixed data';
    await test.step(stepDescription, async () => {
        await this.apiHelper.mockApi(
            stepDescription,
            '/api/collection/summary',
            summary,
        );
    });
}
Enter fullscreen mode Exit fullscreen mode

The most basic example is:

import { expect, test } from '../fixtures';
import { DashboardPage } from '../pages/DashboardPage';
import top5Delay from '../data/mocks/top5Delay.json';
import top5Total from '../data/mocks/top5Total.json';
import top10Limit1 from '../data/mocks/top10Limit1.json';

test.describe('Check Dashboard', () => {
    let dashboardPage: DashboardPage;
    test.use({ storageState: '.auth/admin.json' });

    test.beforeEach(async ({ page, locale }) => {
        dashboardPage = new DashboardPage(page, locale);
        await dashboardPage.mockAllApis();
        await dashboardPage.goTo();
        await dashboardPage.waitForChartsAreVisible();
    });

    // eslint-disable-next-line playwright/expect-expect
    test('Should show dashboard', {
        tag: ['@VisualTesting', '@Dashboard'],
    }, async () => {
        await dashboardPage.checkPageSnapshot();
    });
});
Enter fullscreen mode Exit fullscreen mode

With this approach, if there is a minor change on the charts, you will see an error like this:

Visual testing differences

The difference is that the previous version displayed the details when you clicked on a segment. I changed it to add a table with the table details to make it more accessible.

Previous version

This is the current version:

Current version

To update the snapshots you can execute this command:

npx playwright test --update-snapshots
Enter fullscreen mode Exit fullscreen mode

Unit testing

Unit tests are important because they allow developers to catch bugs early. With .NET EF, you can connect to any database using the same LINQ query code. With this approach, you can add predefined fixed data to check the data returned by the APIs. Usually, developers create unit tests. I added it only as an example; if there is a unit or integration test that compares against the database, it may not be necessary to create another API test for that purpose. You can check that the API returns the correct number of items maximum 5 and the body response.

How to set up an in-memory database.

var options = new DbContextOptionsBuilder<MicrosipContext>()
            .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
            .Options;
using var isolatedContext = new MicrosipContext(options);
Enter fullscreen mode Exit fullscreen mode

For example to add some invoice and data to test I added a function

 /// <summary>
/// for GetTotalByInvoiceAsync/GetTotalByClientAsync to pick it up.
/// </summary>
private static void AddInvoice(PaymentsContext context, int invoiceId, int clientId, decimal amount, DateTime dueDate)
{
        context.Invoices.Add(new Invoices
        {
            Id = invoiceId,
            ClientId = clientId,
            ConceptId = 1,
            InvoiceNumber = $"V{invoiceId:00000000}",
            Date = DateTime.Now,
            Cancel = "N",
            Status = "A",
        });
        context.Ledger.Add(new Ledger
        {
            InvoiceId = invoiceId,
            Cancel = "N",
            Estatus = "A",
            Type = "P",
            Date = DateTime.Now,
            Amount = amount,
            Tax1 = 0,
            Tax2 = 0,
            Tax2 = 0,
            Discount = 0
        });
    }
Enter fullscreen mode Exit fullscreen mode

With this, you can have invoices without payments to check whether the API returns the correct info.

[Fact]
public async Task GetTotalByClient_WithTwoInvoices_AggregatesTotalAndAverageOverdueDays()
{
    // Own in-memory database, independent of the shared   PaymentsContext fixture and of
   // GetDetailed/GetTotalByInvoiceAsync's own tests, so this test's expected values come
   // from hand-picked numbers rather than from another DAO method's output.
   var options = new DbContextOptionsBuilder<PaymentsContext>()
        .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
        .Options;

   using var isolatedContext = new MicrosipContext(options);

   const int clientId = 700;
   isolatedContext.Clientes.Add(new Clientes { ClienteId = clientId, Nombre = "Test Client", Estatus = "A", SujetoIeps = "N" });

   const decimal invoice1Amount = 1000m;
   var invoice1DueDate = DateTime.Now.AddDays(-30);
   const decimal invoice2Amount = 2000m;
   var invoice2DueDate = DateTime.Now.AddDays(-10);

   AddInvoice(isolatedContext, invoiceId: 1, clientId: clientId, amount: invoice1Amount, dueDate: invoice1DueDate);
   AddInvoice(isolatedContext, invoiceId: 2, clientId: clientId, amount: invoice2Amount, dueDate: invoice2DueDate);
        isolatedContext.SaveChanges();

   var collectionDAO = new CollectionDAO(isolatedContext, new ParameterMock());
   var totals = await collectionDAO.GetTotalByClientAsync();

   var expectedTotal = invoice1Amount + invoice2Amount;
   var overdueDays1 = (DateTime.Now.AddDays(1) - invoice1DueDate).Days;
   var overdueDays2 = (DateTime.Now.AddDays(1) - invoice2DueDate).Days;
   var expectedAverageDaysOverdue = (int)Math.Round((overdueDays1 + overdueDays2) / 2.0, MidpointRounding.AwayFromZero);

   var clientTotal = Assert.Single(totals);
   Assert.Equal(clientId, clientTotal.ClientId);
   Assert.Equal(expectedTotal, clientTotal.Total);
   Assert.Equal(expectedAverageDaysOverdue, clientTotal.DaysOverdue);
}
Enter fullscreen mode Exit fullscreen mode

For integration tests that need to compare against the database, you can use test containers. I didn't include it here, but here is an interesting article: Test Container best practices

API Testing

Postman Testing

Postman includes an option to visualize API results with a chart. You can ask the AI option integrated in Postman to create the chart, or you can write the HTML template code and use the function visualize to display the chart on the Visualization tab.

var template = `
<canvas id="myChart" height="200"></canvas>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.5.0/Chart.min.js"></script> 
<script>
var ctx = document.getElementById('myChart');
pm.getData(function (err, value) {
    var myChart = new Chart(ctx, {
        type: 'bar', // Changed chart type to bar
        data: {
            labels: value.response.map(item => item.Client), // Used map to extract 'Client' values
            datasets: [{ 
                label: [],
                    backgroundColor: ["#003f5c", "#58508d", "#bc5090", "#ff6361", "#ffa600"],
                borderWidth: 1,
                data: value.response.map(item => item.Total) // Used map to extract 'Total' values
            }]
        },
         options: {
                     legend: { display: false },
            title: {
                display: true,
                text: 'Top 5 Total'
            },
            scales: {
                yAxes: [{
                    ticks: {
                        beginAtZero: true
                    }
                }]
            }
        }
    });
});
</script>`;

const response = pm.response.json();

pm.visualizer.set(template,{
    response: response
});
Enter fullscreen mode Exit fullscreen mode

In this case, I use both a bar and pie chart, but it can be any type.

Bar chart

Pie chart

Another option to validate the JSON schema is Zod, but if you are using Newman in your pipeline, Zod does not work; you can use the Postman CLI.

With Zod, you can validate the body response like this:

// Zod schema validation
const { z } = pm.require('npm:zod');

const itemSchema = z.object({
    ClientId: z.number(),
    Client: z.string(),
    Total: z.number(),
    DaysOverdue: z.number()
});
Enter fullscreen mode Exit fullscreen mode

I create a general function to reuse in different tests.

CheckArrayZodSchema: function(itemSchema) {
    const { z } = pm.require('npm:zod');
    // Wrap in z.array so the whole response array is validated
    const responseSchema = z.array(itemSchema);

    const response = pm.response.json();

    // Validate data safely
    const validation = responseSchema.safeParse(response);

    pm.test("Response matches Zod schema", () => {
        pm.expect(validation.success).to.be.true;
    });

    // Log helpful details if validation fails
    if (!validation.success) {
        console.error("Zod Validation Failures:", JSON.stringify(validation.error.format(), null, 2));
    }
  }
Enter fullscreen mode Exit fullscreen mode

RestAssured

For RestAssured, I only created the basic tests that check the response body and status code.

[Test]
public async Task GetTop5Total_WithValidUser_ReturnsTop5ClientsWithBiggestDebt()
{
    var response = Given()
        .Header("Authorization", $"Bearer {AuthToken}")
    .When()
        .Get($"{BaseUrl}/{CollectionEndpoint}/top-5-total")
    .Then()
        .StatusCode(200)
     .DeserializeTo<List<TopClient>>();
     await Assert.That(response?.Count).IsEqualTo(5);
}
Enter fullscreen mode Exit fullscreen mode

Restsharp

With RestSharp, I use the same approach as in other tests.

[Test]
public async Task GetTop5Total_WithValidUser_ReturnsTop5ClientsWithBiggestDebt()
{
    var client = ApiClient.Create(Configuration, AuthToken);
    var response = await client.GetAsync<List<TopClient>>($"{CollectionEndpoint}/top-5-total");

    await Assert.That(response.StatusCode).IsEqualTo(200);
    await Assert.That(response.Data?.Count).IsEqualTo(5);
}
Enter fullscreen mode Exit fullscreen mode

Thanks for following along these challenges. Testing is all about continuous learning, so don't hesitate to ask questions or share your feedback below.

If this helped you in any way, feel free to share it with the community.

Top comments (0)