Solving Common Cross-Platform Issues When Working With Flutter

Solving Common Cross-Platform Issues When Working With Flutter

Solving Common Cross-Platform Issues When Working With Flutter

Carmine Zaccagnino

2020-06-18T10:30:00+00:00 2020-06-18T23:34:04+00:00

I’ve seen a lot of confusion online regarding Web development with Flutter and, often, it’s sadly for the wrong reasons.

Specifically, people sometimes confuse it with the older Web-based mobile (and desktop) cross-platform frameworks, which basically were just Web pages running within browsers running within a wrapper app.

That was truly cross-platform in the sense that the interfaces were the same anyway because you only had access to the interfaces normally accessible on the Web.

Flutter isn’t that, though: it runs natively on each platform, and it means each app runs just like it would run if it were written in Java/Kotlin or Objective-C/Swift on Android and iOS, pretty much. You need to know that because this implies that you need to take care of the many differences between these very diverse platforms.

In this article, we’re going to see some of those differences and how to overcome them. More specifically, we’re going to talk about storage and UI differences, which are the ones that most often cause confusion to developers when writing Flutter code that they want to be cross-platform.

Example 1: Storage

I recently wrote on my blog about the need for a different approach to storing JWTs in Web apps when compared to mobile apps.

That is because of the different nature of the platforms’ storage options, and the need to know each and their native development tools.

Web

When you write a Web app, the storage options you have are:

  1. downloading/uploading files to/from disk, which requires user interaction and is therefore only suitable for files meant to be read or created by the user;
  2. using cookies, which may or may not be accessible from JS (depending on whether or not they’re httpOnly) and are automatically sent along with requests to a given domain and saved when they come as part of a response;
  3. using JS localStorage and sessionStorage, accessible by any JS on the website, but only from JS that is part of the pages of that website.

Mobile

The situation when it comes to mobile apps is completely different. The storage options are the following:

  1. local app documents or cache storage, accessible by that app;
  2. other local storage paths for user-created/readable files;
  3. NSUserDefaults and SharedPreferences respectively on iOS and Android for key-value storage;
  4. Keychain on iOS and KeyStore on Android for secure storage of, respectively, any data and cryptographic keys.

If you don’t know that, you’re going to make a mess of your implementations because you need to know what storage solution you’re actually using and what the advantages and drawbacks are.

Cross-Platform Solutions: An Initial Approach

Using the Flutter shared_preferences package uses localStorage on the Web, SharedPreferences on Android and NSUserDefaults on iOS. Those have completely different implications for your app, especially if you’re storing sensitive information like session tokens: localStorage can be read by the client, so it’s a problem if you’re vulnerable to XSS. Even though mobile apps aren’t really vulnerable to XSS, SharedPreferences and NSUserDefaults are not secure storage methods because they can be compromised on the client side since they are not secure storage and not encrypted. That’s because they are meant for user preferences, as mentioned here in the case of iOS and here in the Android documentation when talking about the Security library which is designed to provide wrappers to the SharedPreferences specifically to encrypt the data before storing it.

Secure Storage On Mobile

The only secure storage solutions on mobile are Keychain and KeyStore on iOS and Android respectively, whereas there is no secure storage on the Web.

The Keychain and KeyStore are very different in nature, though: Keychain is a generic credentials storage solution, whereas the KeyStore is used to store (and can generate) cryptographic keys, either symmetric keys or public/private keys.

This means that if, for instance, you need to store a session token, on iOS you can let the OS manage the encryption part and just send your token to the Keychain, whereas on Android it’s a bit more of a manual experience because you need to generate (not hard-code, that’s bad) a key, use it to encrypt the token, store the encrypted token in SharedPreferences and store the key in the KeyStore.

There are different approaches to that, as are most things in security, but the simplest is probably to use symmetric encryption, as there is no need for public key cryptography since your app both encrypts and decrypts the token.

Obviously, you don’t need to write mobile platform-specific code that does all of that, as there is a Flutter plugin that does all of that, for instance.

The Lack Of Secure Storage On the Web

That was, actually, the reason that compelled me to write this post. I wrote about using that package to store JWT on mobile apps and people wanted the Web version of that but, as I said, there is no secure storage on the Web. It doesn’t exist.

Does that mean your JWT has to be out in the open?

No, not at all. You can use httpOnly cookies, can’t you? Those aren’t accessible by JS and are sent only to your server. The issue with that is that they’re always sent to your server, even if one of your users clicks on a GET request URL on someone else’s website and that GET request has side effects you or your user won’t like. This actually works for other request types as well, it’s just more complicated. It’s called Cross-Site Request Forgery and you don’t want that. It’s among the web security threats mentioned in Mozilla’s MDN docs, where you can find a more complete explanation.

There are prevention methods. The most common one is having two tokens, actually: one of them getting to the client as an httpOnly cookie, the other as part of the response. The latter has to be stored in localStorage and not in cookies because we don’t want it to be sent automatically to the server.

Solving Both

What if you have both a mobile app and a Web app?

That can be dealt with in one of two ways:

  1. Use the same backend endpoint, but manually get and send the cookies using the cookie-related HTTP headers;
  2. Create a separate non-Web backend endpoint that generates different token than either token used by the Web app and then allow for regular JWT authorization if the client is able to provide the mobile-only token.

Running Different Code On Different Platforms

Now, let’s see how we can run different code on different platforms in order to be able to compensate for the differences.

Creating A Flutter Plugin

Especially to solve the problem of storage, one way you can do that is with a plugin package: plugins provide a common Dart interface and can run different code on different platforms, including native platform-specific Kotlin/Java or Swift/Objective-C code. Developing packages and plugins is rather complex, but it’s explained in many places on the Web and elsewhere (for example in Flutter books), including the official Flutter documentation.

For mobile platforms, for instance, there already is a secure storage plugin, and that’s flutter_secure_storage, for which you can find an example of usage here, but that doesn’t work on the Web, for example.

On the other hand, for simple key-value storage that also works on the web, there’s a cross-platform Google-developed first-party plugin package called shared_preferences, which has a Web-specific component called shared_preferences_web which uses NSUserDefaults, SharedPreferences or localStorage depending on the platform.

TargetPlatform on Flutter

After importing package:flutter/foundation.dart, you can compare Theme.of(context).platform to the values:

  • TargetPlatform.android
  • TargetPlatform.iOS
  • TargetPlatform.linux
  • TargetPlatform.windows
  • TargetPlatform.macOS
  • TargetPlatform.fuchsia

and write your functions so that, for each platform you want to support, they do the appropriate thing. This will come especially useful for the next example of platform difference, and that is differences in how widgets are displayed on different platforms.

For that use case, in particular, there is also a reasonably popular flutter_platform_widgets plugin, which simplifies the development of platform-aware widgets.

Example 2: Differences In How The Same Widget Is Displayed

You can’t just write cross-platform code and pretend a browser, a phone, a computer, and a smartwatch are the same thing — unless you want your Android and iOS app to be a WebView and your desktop app to be built with Electron. There are plenty of reasons not to do that, and it’s not the point of this piece to convince you to use frameworks like Flutter instead that keep your app native, with all the performance and user experience advantages that come with it, while allowing you to write code that is going to be the same for all platforms most of the time.

That requires care and attention, though, and at least a basic knowledge of the platforms you want to support, their actual native APIs, and all of that. React Native users need to pay even more attention to that because that framework uses the built-in OS widgets, so you actually need to pay even more attention to how the app looks by testing it extensively on both platforms, without being able to switch between iOS and Material widget on the fly like it’s possible with Flutter.

What Changes Without Your Request

There are some aspects of the UI of your app that are automatically changed when you switch platforms. This section also mentions what changes between Flutter and React Native in this respect.

Between Android And iOS (Flutter)

Flutter is capable of rendering Material widgets on iOS (and Cupertino (iOS-like) widgets on Android), but what it DOESN’T do is show exactly the same thing on Android and iOS: Material theming especially adapts to the conventions of each platform.

For instance, navigation animations and transitions and default fonts are different, but those don’t impact your app that much.

What may affect some of your choices when it comes to aesthetics or UX is the fact that some static elements also change. Specifically, some icons change between the two platforms, app bar titles are in the middle on iOS and on the left on Android (on the left of the available space in case there is a back button or the button to open a Drawer (explained here in the Material Design guidelines and also known as a hamburger menu). Here’s what a Material app with a Drawer looks like on Android:

image of an Android app showing where the app bar title appears on Flutter Android Material apps
Material app running on Android: the AppBar title is in the left side of the available space. (Large preview)

And what the same, very simple, Material app looks like on iOS:

image of an iOS app showing where the app bar title appears on Flutter iOS Material apps
Material app running on iOS: the AppBar title is in the middle. (Large preview)
Between Mobile and Web and With Screen Notches (Flutter)

On the Web there is a bit of a different situation, as mentioned also in this Smashing article about Responsive Web Development with Flutter: in particular, in addition to having to optimize for bigger screens and account for the way people expect to navigate through your site — which is the main focus of that article — you have to worry about the fact that sometimes widgets are placed outside of the browser window. Also, some phones have notches in the top part of their screen or other impediments to the correct viewing of your app because of some sort of obstruction.

Both of these problems can be avoided by wrapping your widgets in a SafeArea widget, which is a particular kind of padding widget which makes sure your widgets fall into a place where they can actually be displayed without anything impeding the users’ ability to see them, be it a hardware or software constraint.

In React Native

React Native requires much more attention and a much deeper knowledge of each platform, in addition to requiring you to run the iOS Simulator as well as the Android Emulator at the very least in order to be able to test your app on both platforms: it’s not the same and it converts its JavaScript UI elements to platform-specific widgets. In other words, your React Native apps will always look like iOS — with Cupertino UI elements as they are sometimes called — and your Android apps will always look like regular Material Design Android apps because it’s using the platform’s widgets.

The difference here is that Flutter renders its widgets with its own low-level rendering engine, which means you can test both app versions on one platform.

Getting Around That Issue

Unless you’re going for something very specific, your app is supposed to look different on different platforms otherwise some of your users will be unhappy.

Just like you shouldn’t simply ship a mobile app to the web (as I wrote in the aforementioned Smashing post), you shouldn’t ship an app full of Cupertino widgets to Android users, for example, because it’s going to be confusing for the most part. On the other hand, having the chance to actually run an app that has widgets that are meant for another platform allows you to test the app and show it to people in both versions without having to use two devices for that necessarily.

The Other Side: Using The Wrong Widgets For The Right Reasons

But that also means that you can do most of your Flutter development on a Linux or Windows workstation without sacrificing the experience of your iOS users, and then just build the app for the other platform and not have to worry about thoroughly testing it.

Next Steps

Cross-platform frameworks are awesome, but they shift responsibility to you, the developer, to understand how each platform works and how to make sure your app adapts and is pleasant to use for your users. Other small things to consider may be, for example, using different descriptions for what might be in essence the same thing if there are different conventions on different platforms.

It’s great to not have to build the two (or more) apps separately using different languages, but you still need to keep in mind you are, in essence, building more than one app and that requires thinking about each of the apps you are building.

Further Resources

Smashing Editorial (ra, yk, il)

6 Ways to Convert New Visitors to Email Subscribers

Many online merchants have experienced traffic surges due to the pandemic. Converting that new traffic to immediate purchases can be challenging. The next best option is enticing visitors to subscribe to your email communications.

In this post, I’ll offer tips for converting new visitors into email subscribers.

Converting Visitors to Subscribers

Find the source of current subscribers. Email subscribers come from dozens of sources. The first objective is to identify the top referring channels.

To do this, create a conversion goal in Google Analytics (Conversions > Goals) for your email sign-up or confirmation page. Check the sources of the most subscribers and the most purchases — they may be different.

Once you’ve identified the referring channels, look at the paths of those visitors on your site that led to the sign-up. Knowing those behaviors can help focus your optimization efforts.

Typical sources of new subscribers include:

  • Social media posts,
  • Articles and other free content,
  • Organic search traffic,
  • Paid search traffic,
  • Display ads,
  • Affiliate sites,
  • Contests or promotions.

Target the sources that produce the most subscribers.

Provide an incentive. Email subscribers are valuable. Thus offering an incentive can be worth the effort and expense. Strong incentives, in my experience, are:

  • Contests or giveaways,
  • Gift or cart starter,
  • Exclusive subscriber-only content,
  • Discounts and free shipping,
  • Rewards program.

Feedback and notifications. Requesting feedback such as surveys, product reviews, or quizzes can encourage sign-ups, as can notifications, as in:

  • Availability of out-of-stock items,
  • New product launches,
  • Nearby store openings,
  • Holiday shipping deadlines.

Increase sign-up locations. Adding more opportunities for a visitor to sign up will ultimately help conversions. Email sign up boxes should be easy to locate on every page of your site. Shoppers know to scroll to the bottom of a page to access info such as “about us,” shipping, and customer service. It’s a good place for an email sign-up, too.

Wayfair includes an email sign-up on the lower portion of each page.

Wayfair includes an email sign-up on the lower portion of each page.

Use pop-ups. Capturing email addresses via pop-ups is popular because it works. To maximize effectiveness, however, consider the following.

Dos:

  • Do place pop-ups at the end of content or page exit.
  • Do present a clear call to action with creative imagery.
  • Do request feedback or engagement.
  • Do test! What works for one site may not work for others. Optimize sign-ups with non-stop testing.

Don’ts:

  • Don’t throw a pop-up immediately when a visitor lands on a page.
  • Don’t take up the entire screen with the pop-up.
  • Don’t block device-controlled autofill as it streamlines the process for the user.
  • Don’t ask for too much information.

This example below, from Salt Strong, an online fishing club, is a pop-up that engages the visitor with a quiz before requesting an email address.

<img aria-describedby="caption-attachment-351121" class="wp-image-351121 size-full" src="https://www.practicalecommerce.com/wp-content/uploads/2020/06/Salt-Strong-email-sign-up-quiz.jpg" alt="The pop-up for Salt Strong engages visitors with a quiz before requesting an email address. Source: Optinmonster.” width=”342″ height=”530″ srcset=”https://www.practicalecommerce.com/wp-content/uploads/2020/06/Salt-Strong-email-sign-up-quiz.jpg 342w, https://www.practicalecommerce.com/wp-content/uploads/2020/06/Salt-Strong-email-sign-up-quiz-194×300.jpg 194w, https://www.practicalecommerce.com/wp-content/uploads/2020/06/Salt-Strong-email-sign-up-quiz-150×232.jpg 150w, https://www.practicalecommerce.com/wp-content/uploads/2020/06/Salt-Strong-email-sign-up-quiz-323×500.jpg 323w” sizes=”(max-width: 342px) 100vw, 342px”>

The pop-up for Salt Strong engages visitors with a quiz before requesting an email address. Source: Optinmonster.

Find partners. Look for opportunities to partner with your affiliates, sites with complementary content or products, and co-registration options. Opt-Intelligence and AddShoppers, for example, can promote your email sign-up to a network of potential partners.

Unsubscribes

It’s important to grow email sign-ups. But the key is quality, relevant subscribers that will not unsubscribe. Check your analytics to understand the sources of unsubscribes. (An unsubscribe confirmation page can facilitate.)

A critical data point is comparing the subscribe and unsubscribe dates. If the dates are close, try to determine where the subscriber came from. He could be taking advantage of a lucrative offer — not long-term engagement.

Mirage JS Deep Dive: Using Mirage JS And Cypress For UI Testing (Part 4)

Mirage JS Deep Dive: Using Mirage JS And Cypress For UI Testing (Part 4)

Mirage JS Deep Dive: Using Mirage JS And Cypress For UI Testing (Part 4)

Kelvin Omereshone

2020-06-17T10:30:00+00:00 2020-06-17T23:33:57+00:00

One of my favorite quotes about software testing is from the Flutter documentation. It says:

“How can you ensure that your app continues to work as you add more features or change existing functionality? By writing tests.”

On that note, this last part of the Mirage JS Deep Dive series will focus on using Mirage to test your JavaScript front-end application.

Note: This article assumes a Cypress environment. Cypress is a testing framework for UI testing. You can, however, transfer the knowledge here to whatever UI testing environment or framework you use.

Read Previous Parts Of The Series:

  • Part 1: Understanding Mirage JS Models And Associations
  • Part 2: Understanding Factories, Fixtures And Serializers
  • Part 3: Understanding Timing, Response And Passthrough

UI Tests Primer

UI or User Interface test is a form of acceptance testing done to verify the user flows of your front-end application. The emphasis of these kinds of software tests is on the end-user that is the actual person who will be interacting with your web application on a variety of devices ranging from desktops, laptops to mobile devices. These users would be interfacing or interacting with your application using input devices such as a keyboard, mouse, or touch screens. UI tests, therefore, are written to mimic the user interaction with your application as close as possible.

Let’s take an e-commerce website for example. A typical UI test scenario would be:

  • The user can view the list of products when visiting the homepage.

Other UI test scenarios might be:

  • The user can see the name of a product on the product’s detail page.
  • The user can click on the “add to cart” button.
  • The user can checkout.

You get the idea, right?

In making UI Tests, you will mostly be relying on your back-end states, i.e. did it return the products or an error? The role Mirage plays in this is to make those server states available for you to tweak as you need. So instead of making an actual request to your production server in your UI tests, you make the request to Mirage mock server.

For the remaining part of this article, we will be performing UI tests on a fictitious e-commerce web application UI. So let’s get started.

Our First UI Test

As earlier stated, this article assumes a Cypress environment. Cypress makes testing UI on the web fast and easy. You could simulate clicks and navigation and you can programmatically visit routes in your application. See the docs for more on Cypress.

So, assuming Cypress and Mirage are available to us, let’s start off by defining a proxy function for your API request. We can do so in the support/index.js file of our Cypress setup. Just paste the following code in:

// cypress/support/index.js
Cypress.on("window:before:load", (win) => { win.handleFromCypress = function (request) { return fetch(request.url, { method: request.method, headers: request.requestHeaders, body: request.requestBody, }).then((res) => { let content = res.headers.map["content-type"] === "application/json" ? res.json() : res.text() return new Promise((resolve) => { content.then((body) => resolve([res.status, res.headers, body])) }) }) }
})

Then, in your app bootstrapping file (main.js for Vue, index.js for React), we’ll use Mirage to proxy your app’s API requests to the handleFromCypress function only when Cypress is running. Here is the code for that:

import { Server, Response } from "miragejs" if (window.Cypress) { new Server({ environment: "test", routes() { let methods = ["get", "put", "patch", "post", "delete"] methods.forEach((method) => { this[method]("/*", async (schema, request) => { let [status, headers, body] = await window.handleFromCypress(request) return new Response(status, headers, body) }) }) }, })
}

With that setup, anytime Cypress is running, your app knows to use Mirage as the mock server for all API requests.

Let’s continue writing some UI tests. We’ll begin by testing our homepage to see if it has 5 products displayed. To do this in Cypress, we need to create a homepage.test.js file in the tests folder in the root of your project directory. Next, we’ll tell Cypress to do the following:

  • Visit the homepage i.e / route
  • Then assert if it has li elements with the class of product and also checks if they are 5 in numbers.

Here is the code:

// homepage.test.js
it('shows the products', () => { cy.visit('/'); cy.get('li.product').should('have.length', 5);
});

You might have guessed that this test would fail because we don’t have a production server returning 5 products to our front-end application. So what do we do? We mock out the server in Mirage! If we bring in Mirage, it can intercept all network calls in our tests. Let’s do this below and start the Mirage server before each test in the beforeEach function and also shut it down in the afterEach function. The beforeEach and afterEach functions are both provided by Cypress and they were made available so you could run code before and after each test run in your test suite — hence the name. So let’s see the code for this:

// homepage.test.js
import { Server } from "miragejs" let server beforeEach(() => { server = new Server()
}) afterEach(() => { server.shutdown()
}) it("shows the products", function () { cy.visit("/") cy.get("li.product").should("have.length", 5)
})

Okay, we are getting somewhere; we have imported the Server from Mirage and we are starting it and shutting it down in beforeEach and afterEach functions respectively. Let’s go about mocking our product resource.


// homepage.test.js
import { Server, Model } from 'miragejs'; let server; beforeEach(() => { server = new Server({ models: { product: Model, }, routes() { this.namespace = 'api'; this.get('products', ({ products }, request) => { return products.all(); }); }, });
}); afterEach(() => { server.shutdown();
}); it('shows the products', function() { cy.visit('/'); cy.get('li.product').should('have.length', 5);
});

Note: You can always take a peek at the previous parts of this series if you don’t understand the Mirage bits of the above code snippet.

  • Part 1: Understanding Mirage JS Models And Associations
  • Part 2: Understanding Factories, Fixtures And Serializers
  • Part 3: Understanding Timing, Response And Passthrough

Okay, we have started fleshing out our Server instance by creating the product model and also by creating the route handler for the /api/products route. However, if we run our tests, it will fail because we don’t have any products in the Mirage database yet.

Let’s populate the Mirage database with some products. In order to do this, we could have used the create() method on our server instance, but creating 5 products by hand seems pretty tedious. There should be a better way.

Ah yes, there is. Let’s utilize factories (as explained in the second part of this series). We’ll need to create our product factory like so:

// homepage.test.js
import { Server, Model, Factory } from 'miragejs'; let server; beforeEach(() => { server = new Server({ models: { product: Model, }, factories: { product: Factory.extend({ name(i) { return `Product ${i}` } }) }, routes() { this.namespace = 'api'; this.get('products', ({ products }, request) => { return products.all(); }); }, });
}); afterEach(() => { server.shutdown();
}); it('shows the products', function() { cy.visit('/'); cy.get('li.product').should('have.length', 5);
});

Then, finally, we’ll use createList() to quickly create the 5 products that our test needs to pass.

Let’s do this:

// homepage.test.js
import { Server, Model, Factory } from 'miragejs'; let server; beforeEach(() => { server = new Server({ models: { product: Model, }, factories: { product: Factory.extend({ name(i) { return `Product ${i}` } }) }, routes() { this.namespace = 'api'; this.get('products', ({ products }, request) => { return products.all(); }); }, });
}); afterEach(() => { server.shutdown();
}); it('shows the products', function() { server.createList("product", 5) cy.visit('/'); cy.get('li.product').should('have.length', 5);
});

So when we run our test, it passes!

Note: After each test, Mirage’s server is shutdown and reset, so none of this state will leak across tests.

Avoiding Multiple Mirage Server

If you have been following along this series, you’d notice when we were using Mirage in development to intercept our network requests; we had a server.js file in the root of our app where we set up Mirage. In the spirit of DRY (Don’t Repeat Yourself), I think it would be good to utilize that server instance instead of having two separate instances of Mirage for both development and testing. To do this (in case you don’t have a server.js file already), just create one in your project src directory.

Note: Your structure will differ if you are using a JavaScript framework but the general idea is to setup up the server.js file in the src root of your project.

So with this new structure, we’ll export a function in server.js that is responsible for creating our Mirage server instance. Let’s do that:

// src/server.js export function makeServer() { /* Mirage code goes here */}

Let’s complete the implementation of the makeServer function by removing the Mirage JS server we created in homepage.test.js and adding it to the makeServer function body:

import { Server, Model, Factory } from 'miragejs'; export function makeServer() { let server = new Server({ models: { product: Model, }, factories: { product: Factory.extend({ name(i) { return `Product ${i}`; }, }), }, routes() { this.namespace = 'api'; this.get('/products', ({ products }) => { return products.all(); }); }, seeds(server) { server.createList('product', 5); }, }); return server;
}

Now all you have to do is import makeServer in your test. Using a single Mirage Server instance is cleaner; this way you don’t have to maintain two server instances for both development and test environments.

After importing the makeServer function, our test should now look like this:

import { makeServer } from '/path/to/server'; let server; beforeEach(() => { server = makeServer();
}); afterEach(() => { server.shutdown();
}); it('shows the products', function() { server.createList('product', 5); cy.visit('/'); cy.get('li.product').should('have.length', 5);
});

So we now have a central Mirage server that serves us in both development and testing. You can also use the makeServer function to start Mirage in development (see first part of this series).

Your Mirage code should not find it’s way into production. Therefore, depending on your build setup, you would need to only start Mirage during development mode.

Note: Read my article on how to set up API Mocking with Mirage and Vue.js to see how I did that in Vue so you could replicate in whatever front-end framework you use.

Testing Environment

Mirage has two environments: development (default) and test. In development mode, the Mirage server will have a default response time of 400ms(which you can customize. See the third article of this series for that), logs all server responses to the console, and loads the development seeds.

However, in the test environment, we have:

  • 0 delays to keep our tests fast
  • Mirage suppresses all logs so as not to pollute your CI logs
  • Mirage will also ignore the seeds() function so that your seed data can be used solely for development but won’t leak into your tests. This helps keep your tests deterministic.

Let’s update our makeServer so we can have the benefit of the test environment. To do that, we’ll make it accept an object with the environment option(we will default it to development and override it in our test). Our server.js should now look like this:

// src/server.js
import { Server, Model, Factory } from 'miragejs'; export function makeServer({ environment = 'development' } = {}) { let server = new Server({ environment, models: { product: Model, }, factories: { product: Factory.extend({ name(i) { return `Product ${i}`; }, }), }, routes() { this.namespace = 'api'; this.get('/products', ({ products }) => { return products.all(); }); }, seeds(server) { server.createList('product', 5); }, }); return server;
}

Also note that we are passing the environment option to the Mirage server instance using the ES6 property shorthand. Now with this in place, let’s update our test to override the environment value to test. Our test now looks like this:

import { makeServer } from '/path/to/server'; let server; beforeEach(() => { server = makeServer({ environment: 'test' });
}); afterEach(() => { server.shutdown();
}); it('shows the products', function() { server.createList('product', 5); cy.visit('/'); cy.get('li.product').should('have.length', 5);
});

AAA Testing

Mirage encourages a standard for testing called the triple-A or AAA testing approach. This stands for Arrange, Act and Assert. You could see this structure in our above test already:

it("shows all the products", function () { // ARRANGE server.createList("product", 5) // ACT cy.visit("/") // ASSERT cy.get("li.product").should("have.length", 5)
})

You might need to break this pattern but 9 times out of 10 it should work just fine for your tests.

Let’s Test Errors

So far, we’ve tested our homepage to see if it has 5 products, however, what if the server is down or something went wrong with fetching the products? We don’t need to wait for the server to be down to work on how our UI would look like in such a case. We can simply simulate that scenario with Mirage.

Let’s return a 500 (Server error) when the user is on the homepage. As we have seen in a previous article, to customize Mirage responses we make use of the Response class. Let’s import it and write our test.

homepage.test.js
import { Response } from "miragejs" it('shows an error when fetching products fails', function() { server.get('/products', () => { return new Response( 500, {}, { error: "Can’t fetch products at this time" } ); }); cy.visit('/'); cy.get('div.error').should('contain', "Can’t fetch products at this time");
});

What a world of flexibility! We just override the response Mirage would return in order to test how our UI would display if it failed fetching products. Our overall homepage.test.js file would now look like this:

// homepage.test.js
import { Response } from 'miragejs';
import { makeServer } from 'path/to/server'; let server; beforeEach(() => { server = makeServer({ environment: 'test' });
}); afterEach(() => { server.shutdown();
}); it('shows the products', function() { server.createList('product', 5); cy.visit('/'); cy.get('li.product').should('have.length', 5);
}); it('shows an error when fetching products fails', function() { server.get('/products', () => { return new Response( 500, {}, { error: "Can’t fetch products at this time" } ); }); cy.visit('/'); cy.get('div.error').should('contain', "Can’t fetch products at this time");
});

Note the modification we did to the /api/products handler only lives in our test. That means it works as we previously define when you are in development mode.

So when we run our tests, both should pass.

Note: I believe its worthy of noting that the elements we are querying for in Cypress should exist in your front-end UI. Cypress doesn’t create HTML elements for you.

Testing The Product Detail Page

Finally, let’s test the UI of the product detail page. So this is what we are testing for:

  • User can see the product name on the product detail page

Let’s get to it. First, we create a new test to test this user flow.

Here is the test:

it("shows the product’s name on the detail route", function() { let product = this.server.create('product', { name: 'Korg Piano', }); cy.visit(`/${product.id}`); cy.get('h1').should('contain', 'Korg Piano');
});

Your homepage.test.js should finally look like this.

// homepage.test.js
import { Response } from 'miragejs';
import { makeServer } from 'path/to/server; let server; beforeEach(() => { server = makeServer({ environment: 'test' });
}); afterEach(() => { server.shutdown();
}); it('shows the products', function() { console.log(server); server.createList('product', 5); cy.visit('/'); cy.get('li.product').should('have.length', 5);
}); it('shows an error when fetching products fails', function() { server.get('/products', () => { return new Response( 500, {}, { error: "Can’t fetch products at this time" } ); }); cy.visit('/'); cy.get('div.error').should('contain', "Can’t fetch products at this time");
}); it("shows the product’s name on the detail route", function() { let product = server.create('product', { name: 'Korg Piano', }); cy.visit(`/${product.id}`); cy.get('h1').should('contain', 'Korg Piano');
});

When you run your tests, all three should pass.

Wrapping Up

It’s been fun showing you the inners of Mirage JS in this series. I hope you have been better equipped to start having a better front-end development experience by using Mirage to mock out your back-end server. I also hope you’ll use the knowledge from this article to write more acceptance/UI/end-to-end tests for your front-end applications.

  • Part 1: Understanding Mirage JS Models And Associations
  • Part 2: Understanding Factories, Fixtures And Serializers
  • Part 3: Understanding Timing, Response And Passthrough
  • Part 4: Using Mirage JS And Cypress For UI Testing
Smashing Editorial (ra, il)