Skip to content

pairing4good/tdd-amplify-react-native

Repository files navigation

TDD React Native App

Node CI

This is the second part of a two part tutorial. In the first tutorial we created a React Notes App that uses an AWS Amplify backend to secure and store notes. In this tutorial we will hook up a native mobile app that will use the same backend service that we built in the first tutorial.

Prerequisites

Set Up

Set Up

  • Run npm install --global expo-cli

  • cd to the directory where you store your git repositories

  • Run expo init tdd-amplify-react-native and select the blank template when prompted.

  • Run cd tdd-amplify-react-native

  • Run npm start

  • In the Metro Bundler window found at http://localhost:19002/ click the Run in web browser option on the left navigation

  • You should see the following message in your browser Open up App.js to start working on your app!

  • Commit

Code for this section

First Test

First Test

  • In a new terminal window run npm install cypress --save-dev to install Cypress via npm:
  • Run npx cypress open
  • Configure the base url in the cypress.json file
{
    "baseUrl": "http://localhost:19006"
}
  • One of the benefits of using Expo is that it provides multiple ways to access your application. For this test we are using the web browser version to quickly verify the apps behavior.

  • Run one or two of the Cypress examples to make sure everything is set up correctly.

  • Once you have verified that Cypress is running correctly, delete the cypress/integration/examples/ directory so that your tests will run faster on your Continuous Integration (CI) Server.

  • Create a new test called note.spec.js under the cypress\integration\ directory in your project

  • Add the following tests to drive the same UI that you created in the first tutorial.

describe("Note Capture", () => {
  before(() => {
    cy.visit("/");
  });

  it("should have header", () => {
    cy.get("[data-testid=note-header]").should("have.text", "My Notes App");
  });

  it("should create a note when name and description provided", () => {
    //cy.get('[data-testid=test-name-0]').should('not.exist');
    //cy.get('[data-testid=test-description-0]').should('not.exist');

    cy.get("[data-testid=note-name-field]").type("test note");
    cy.get("[data-testid=note-description-field]").type(
      "test note description"
    );
    cy.get("[data-testid=note-form-submit]").click();

    // cy.get('[data-testid=note-name-field]').should('have.value', '');
    // cy.get('[data-testid=note-description-field]').should('have.value', '');

    cy.get("[data-testid=test-name-0]").should("have.text", "test note");
    cy.get("[data-testid=test-description-0]").should(
      "have.text",
      "test note description"
    );
  });

  it("should delete note", () => {
    cy.get("[data-testid=test-button-0]").click();

    // cy.get('[data-testid=test-name-0]').should('not.exist')
    // cy.get('[data-testid=test-description-0]').should('not.exist')
  });

  it("should have an option to sign out", () => {
    cy.get("[data-testid=aws-amplify__auth--sign-out-button]").click();
    cy.get(
      "[data-testid=aws-amplify__auth--sign-in-to-your-account-text]"
    ).should("exist");
  });
});
  • The commented out lines (//) will not work until we hook up the backend API

  • Run expo start --web

Before we proceed let's add a script to run cypress into the package.json file in the scripts section.

"scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "cypress:open": "cypress open"
  }
  • Now you can run npm run cypress:open to open cypress

  • Select the note.spec.js test

  • The tests are Red

Our objective will be to get to Green as quickly as we can in the simplest way possible. Since the backend already exists we will use it as is and build out just enough UI to make it turn Green. Once it is Green then we will Refactor.

Code for this section

Build The UI

Build The UI

Build out the simplest UI that will cause the Cypress test to go Green. Once we have green then we will refactor and expand the UI's functionality.

import React from "react";
import { Text, View, TextInput, Button } from "react-native";

export default function App() {
  return (
    <View>
      <Text testID="note-header">My Notes App</Text>
      <TextInput testID="note-name-field" />
      <TextInput testID="note-description-field" />
      <Button testID="note-form-submit" title="Create Note" />

      <Text testID="test-name-0">test note</Text>
      <Text testID="test-description-0">test note description</Text>
      <Button testID="test-button-0" title="Delete note" />
    </View>
  );
}
  • React Native uses different components than React

  • In React Native testID replaces React's data-testid but they both render to the same element id in the web.

  • Run the Cypress test

  • Green

  • Commit

Code for this section

Connect Backend Auth

Connect Backend Auth

We want to reuse the same Amplify backend authentication that we created in the first tutorial.

  • Go to http://console.aws.amazon.com/
  • Select AWS Amplify
  • Select the application you created in the first tutorial
  • Select the Backend environments tab
  • Select the Local setup instructions section
  • Copy the provided command (ie: amplify pull --appId xxxxxxxxxxx --envName xxx)
  • Run the command you copied at the root of your project
? Choose your default editor: Visual Studio Code
? Choose the type of app that you're building: javascript
Please tell us about your project
? What javascript framework are you using: react-native
? Source Directory Path:  /
? Distribution Directory Path: /
? Build Command:  npm run-script build
? Start Command: npm run-script start
? Do you plan on modifying this backend?: No
  • This created the aws-export.js and added it to .gitignore so that your user credentials are not committed

  • Run npm install aws-amplify-react-native

  • Add authentication to App.js

...
import { withAuthenticator } from "aws-amplify-react-native"
import Amplify from "aws-amplify"
import awsconfig from './aws-exports';

Amplify.configure({
  ...awsconfig,
  Analytics: {
    disabled: true,
  },
});

function App() {
  return (
...
  );
}

export default withAuthenticator(App, true)
  • The aws-amplify-react-native library has an issue that requires adding the Analytics: {disabled: true} option to the Amplify.configure function.

  • Add the following to the bottom of the cypress/support/commands.js file

const Auth = require("aws-amplify").Auth;
import "cypress-localstorage-commands";
const username = Cypress.env("username");
const password = Cypress.env("password");
const userPoolId = Cypress.env("userPoolId");
const clientId = Cypress.env("clientId");

const awsconfig = {
  aws_user_pools_id: userPoolId,
  aws_user_pools_web_client_id: clientId,
};
Auth.configure(awsconfig);

Cypress.Commands.add("signIn", () => {
  cy.then(() => Auth.signIn(username, password)).then((cognitoUser) => {
    const idToken = cognitoUser.signInUserSession.idToken.jwtToken;
    const accessToken = cognitoUser.signInUserSession.accessToken.jwtToken;

    const makeKey = (name) => `CognitoIdentityServiceProvider
        .${cognitoUser.pool.clientId}
        .${cognitoUser.username}.${name}`;

    cy.setLocalStorage(makeKey("accessToken"), accessToken);
    cy.setLocalStorage(makeKey("idToken"), idToken);
    cy.setLocalStorage(
      `CognitoIdentityServiceProvider.${cognitoUser.pool.clientId}.LastAuthUser`,
      cognitoUser.username
    );
  });
  cy.saveLocalStorage();
});
  • Create a new file at the root of your project named cypress.env.json with the following content
{
  "username": "[Login username you just created]",
  "password": "[Login password you just created]",
  "userPoolId": "[The `aws_user_pools_id` value found in your `src/aws-exports.js`]",
  "clientId": "[The `aws_user_pools_web_client_id` value found in your `src/aws-exports.js`]"
}
  • Add cypress.env.json to .gitignore so that it will not be committed and pushed to GitHub
#amplify
amplify/\#current-cloud-backend
...
amplifyconfiguration.dart
amplify-build-config.json
amplify-gradle-config.json
amplifytools.xcconfig
.secret-*
cypress.env.json
  • Add the following set ups and tear downs to cypress/integration/note.spec.js
before(() => {
  cy.signIn();
  cy.visit("/");
});

after(() => {
  cy.clearLocalStorageSnapshot();
  cy.clearLocalStorage();
});

beforeEach(() => {
  cy.restoreLocalStorage();
});

afterEach(() => {
  cy.saveLocalStorage();
});
  • Run the Cypress tests
  • Green!
  • Commit

Code for this section

Connect Backend API

Connect Backend API

We want to reuse the same Amplify backend API that we created in the first tutorial.

  • Go to http://console.aws.amazon.com/
  • Select AWS AppSync
  • Select the application you created in the first tutorial
  • In the Integrate with your app section select the JavaScript tab
  • Copy the amplify add codegen --apiId xxxxxxxxxxxxxxxxxxxx command
  • Select Schema on the left navigation bar
  • Click the Export schema dropdown
  • Select schema.json
  • Once it has downloaded move the file to the root of your project
  • Run the command you copied (amplify add codegen --apiId xxxxxxxxxxxxxxxxxxxx)
? Choose the type of app that you're building: javascript
? What javascript framework are you using: react-native
? Choose the code generation language target: javascript
? Enter the file name pattern of graphql queries, mutations and subscriptions: src/graphql/**/*.js
? Do you want to generate/update all possible GraphQL operations - queries, mutations and subscriptions: Yes
? Enter maximum statement depth [increase from default if your schema is deeply nested]: 2
  • Create a new folder in the src directory called test
  • Create a new file named NoteRepository.test.js
import { save, findAll, deleteById } from "../common/NoteRepository";
import { API } from "aws-amplify";
import {
  createNote as createNoteMutation,
  deleteNote as deleteNoteMutation,
} from "../graphql/mutations";
import { listNotes } from "../graphql/queries";

const mockGraphql = jest.fn();
const id = "test-id";

beforeEach(() => {
  API.graphql = mockGraphql;
});

afterEach(() => {
  jest.clearAllMocks();
});

it("should create a new note", () => {
  const note = { name: "test name", description: "test description" };

  save(note);

  expect(mockGraphql.mock.calls.length).toBe(1);
  expect(mockGraphql.mock.calls[0][0]).toStrictEqual({
    query: createNoteMutation,
    variables: { input: note },
  });
});

it("should findAll notes", () => {
  const note = { name: "test name", description: "test description" };

  findAll(note);

  expect(mockGraphql.mock.calls.length).toBe(1);
  expect(mockGraphql.mock.calls[0][0]).toStrictEqual({ query: listNotes });
});

it("should delete note by id", () => {
  deleteById(id);

  expect(mockGraphql.mock.calls.length).toBe(1);
  expect(mockGraphql.mock.calls[0][0]).toStrictEqual({
    query: deleteNoteMutation,
    variables: { input: { id } },
  });
});
  • Run npm install jest-expo --save-dev
  • Add the following to your package.json file
"scripts": {
  ...
  "test": "jest --watch --testPathPattern=src/test"
},
"jest": {
  "preset": "jest-expo"
}
  • Run npm install react-test-renderer --save-dev

  • Run npm install @react-native-community/netinfo

  • Run npm run test

  • The tests go Red

  • Create a new folder in the src directory called common

  • Create a new file named NoteRepository.js

import { API } from "aws-amplify";
import { listNotes } from "../graphql/queries";

export async function findAll() {
  const apiData = await API.graphql({ query: listNotes });
  return apiData.data.listNotes.items;
}
  • One test goes Green
...
import { createNote as createNoteMutation, deleteNote as deleteNoteMutation} from '../graphql/mutations';

...

export async function save(note){
    const apiData = await API.graphql({ query: createNoteMutation, variables: { input: note } });
    return apiData.data.createNote;
}
  • One more test goes Green
export async function deleteById(id) {
  return await API.graphql({
    query: deleteNoteMutation,
    variables: { input: { id } },
  });
}
  • The final test goes Green
  • Run the Cypress tests.
  • Green!
  • Commit

Code for this section

Connect Repository To UI

Connect Repository To UI

Now we will test drive the creation and listing of notes

  • Uncomment the assertions that will drive us to save the note in cypress/integration/note.spec.js
    it('should create a note when name and description provided', () => {
        cy.get('[data-testid=test-name-0]').should('not.exist');
        cy.get('[data-testid=test-description-0]').should('not.exist');
  • We have a failing test that will drive our production code changes.
import React, { useState, useEffect } from 'react';
...
import { findAll, save } from './src/common/NoteRepository';
...

function App() {
  const [notes, setNotes] = useState([]);
  const [formData, setFormData] = useState({ name: '', description: '' });

  useEffect(() => {
    fetchNotesCallback();
  }, []);

  async function fetchNotesCallback() {
    const notes = await findAll()
    if(notes)
      setNotes(notes);
    else
      setNotes([])
  }

  async function createNote() {
    const newNote = await save(formData);
    const updatedNoteList = [ ...notes, newNote ];
    setNotes(updatedNoteList);
  }

  return (
    <View>
      ...
      <TextInput testID="note-name-field"
        onChangeText={text => setFormData({
          ...formData, 'name': text}
        )}
        value={formData.name}/>

      <TextInput testID="note-description-field"
        onChangeText={text => setFormData({
          ...formData, 'description': text}
        )}
        value={formData.description}/>

      <Button testID="note-form-submit"
        title="Create Note"
        onPress={createNote}/>

      {
        notes.map((note, index) => (
          <div>
            <Text testID={"test-name-" + index}>{note.name}</Text>
            <Text testID={"test-description-" + index}>{note.description}</Text>
            <Button testID={"test-button-" + index} title="Delete note" />
          </div>
        ))
      }
    </View>
  );
}
...

Here are syntax differences between React and React Native

  • React's onChange is replaced with onChangeText in React Native

  • React passes an event e to the onChange function where React Native just passes the actual text to the onChangeText function

  • React's onClick is replaced with onPress in React Native

  • Rerun all of the tests

  • Green!

  • Commit

Code for this section

Clear Form After Save

Clear Form After Save

Now we will test drive clearing the form values on save

  • Uncomment the assertions that will drive us to clear the note form in cypress/integration/note.spec.js
cy.get("[data-testid=note-name-field]").should("have.value", "");
cy.get("[data-testid=note-description-field]").should("have.value", "");
  • We have a failing test that will drive our production code changes.
...
function App() {
...
  async function createNote() {
    ...
    setFormData({name: '', description: ''});
  }
...
  • Rerun all of the tests
  • Green!
  • Commit

Code for this section

Hook Up Note Deletion

Hook Up Note Deletion

Now we will test drive the deletion of a note

  • Uncomment the assertions that will drive us to delete a note in cypress/integration/note.spec.js
cy.get("[data-testid=test-name-0]").should("not.exist");
cy.get("[data-testid=test-description-0]").should("not.exist");
  • We have a failing test that will drive our production code changes.
...
import { findAll, save, deleteById } from './src/common/NoteRepository';
...

function App() {
  ...

  async function deleteNoteCallback( id ) {
    const newNotesArray = notes.filter(note => note.id !== id);
    setNotes(newNotesArray);
    await deleteById(id);
  }

  return (
    <View>
      ...

      <Button testID="note-form-submit"
        title="Create Note"
        onPress={createNote}/>

      {
        notes.map((note, index) => (
          <div>
            ...
            <Button testID={"test-button-" + index}
              onPress={() => deleteNoteCallback(note.id)}
              title="Delete note" />
          </div>
        ))
      }
    </View>
  );
}
...
  • Rerun all of the tests
  • Green!
  • Commit

Code for this section

Single Responsibility

Single Responsibility

The App component is doing way too much. Let's pull the form and the list out into separate components.

  • Create a new note folder in the src directory
  • Create a new component named NoteForm.js in the note directory
  • Copy the form to this new component
import React from "react";
import { TextInput, Button } from "react-native";

function NoteForm(props) {
  return (
    <div>
      <TextInput
        testID="note-name-field"
        onChangeText={(text) =>
          props.setFormData({
            ...props.formData,
            name: text,
          })
        }
        value={props.formData.name}
      />

      <TextInput
        testID="note-description-field"
        onChangeText={(text) =>
          props.setFormData({
            ...props.formData,
            description: text,
          })
        }
        value={props.formData.description}
      />

      <Button
        testID="note-form-submit"
        title="Create Note"
        onPress={props.createNote}
      />
    </div>
  );
}

export default NoteForm;
  • Add the NoteForm component to App.js
...
import { Text, View, Button } from 'react-native';
...
import NoteForm from './src/note/NoteForm';

...

  return (
    <View>
      ...

      <NoteForm setFormData={setFormData}
        formData={formData}
        createNote={createNote}/>

      ...
    </View>
  );
}
...
  • Rerun all of your tests

  • Green

  • Pull out a Header component

import React from "react";
import { Text } from "react-native";

function Header() {
  return <Text testID="note-header">My Notes App</Text>;
}

export default Header;
...
import Header from './src/note/Header';
...
  return (
    <View>
      <Header/>
      ...
    </View>
  );
}
...
  • Rerun all of your tests

  • Green

  • Pull out a NoteList component

import React from "react";
import { Text, Button } from "react-native";

function NoteList(props) {
  return (
    <div>
      {props.notes.map((note, index) => (
        <div>
          <Text testID={"test-name-" + index}>{note.name}</Text>
          <Text testID={"test-description-" + index}>{note.description}</Text>
          <Button
            testID={"test-button-" + index}
            onPress={() => props.deleteNoteCallback(note.id)}
            title="Delete note"
          />
        </div>
      ))}
    </div>
  );
}

export default NoteList;
...
import { View} from 'react-native';
...
import NoteList from './src/note/NoteList';

...

function App() {
  ...
  return (
    <View>
      ...
      <NoteList notes={notes}
        deleteNoteCallback={deleteNoteCallback}/>
    </View>
  );
}
...
  • Rerun all of your tests
  • Green
  • Commit

Code for this section

Component Testing

Component Testing

Now that each concern has been pulled out into focused components, we need to move down the testing pyramid and write non-UI tests.

  • Run npm install --save-dev @testing-library/react-native

  • Create a new test Header.test.js in the src/test/ directory

import React from "react";
import { render } from "@testing-library/react-native";
import Header from "../note/Header";

test("should display header", () => {
  const { getByTestId } = render(<Header />);
  const heading = getByTestId("note-header");
  expect(heading.props.children).toBe("My Notes App");
});
  • Run all the tests

  • Green

  • Create a new test NoteList.test.js in the src/test/ directory

import React from "react";
import { render, screen, fireEvent } from "@testing-library/react-native";
import NoteList from "../note/NoteList";

const deleteNoteCallback = jest.fn();

const defaultProps = {
  notes: [],
  deleteNoteCallback: deleteNoteCallback,
};

const setup = (props = {}) => {
  const setupProps = { ...defaultProps, ...props };
  return render(<NoteList {...setupProps} />);
};

test("should display nothing when no notes are provided", () => {
  const { queryByTestId } = setup();
  const firstNoteName = queryByTestId("test-name-0");

  expect(firstNoteName).toBeNull();
});
  • Run all the tests

  • Green

  • Add another NoteList test

test("should display one note when one notes is provided", () => {
  const note = { name: "test name", description: "test description" };
  const { queryByTestId } = setup({ notes: [note] });

  const firstNoteName = queryByTestId("test-name-0");
  expect(firstNoteName.props.children).toBe("test name");

  const firstNoteDescription = queryByTestId("test-description-0");
  expect(firstNoteDescription.props.children).toBe("test description");
});
  • Run all the tests

  • Green

  • Add another NoteList test

test("should display one note when one notes is provided", () => {
  const firstNote = { name: "test name 1", description: "test description 1" };
  const secondNote = { name: "test name 2", description: "test description 2" };
  const { queryByTestId } = setup({ notes: [firstNote, secondNote] });

  const firstNoteName = queryByTestId("test-name-0");
  expect(firstNoteName.props.children).toBe("test name 1");

  const firstNoteDescription = queryByTestId("test-description-0");
  expect(firstNoteDescription.props.children).toBe("test description 1");

  const secondNoteName = queryByTestId("test-name-1");
  expect(secondNoteName.props.children).toBe("test name 2");

  const secondNoteDescription = queryByTestId("test-description-1");
  expect(secondNoteDescription.props.children).toBe("test description 2");
});
  • Run all the tests

  • Green

  • Add another NoteList test

test("should delete note when clicked", () => {
  const note = {
    id: 1,
    name: "test name 1",
    description: "test description 1",
  };
  const notes = [note];
  const { getByTestId } = setup({ notes: notes });
  const button = getByTestId("test-button-0");

  fireEvent.press(button);

  expect(deleteNoteCallback.mock.calls.length).toBe(1);
  expect(deleteNoteCallback.mock.calls[0][0]).toStrictEqual(1);
});
  • Run all the tests

  • Green

  • Add another NoteList test

test("should throw an exception the note array is undefined", () => {
  expect(() => {
    render(<NoteList />);
  }).toThrowError();
});
  • Run all the tests

  • Green

  • Create a new test NoteForm.test.js in the src/test/ directory

import React from "react";
import { render, screen, fireEvent } from "@testing-library/react-native";
import NoteForm from "../note/NoteForm";
import "@testing-library/jest-dom/extend-expect";

const createNoteCallback = jest.fn();
const setFormDataCallback = jest.fn();
const formData = { name: "", description: "" };

const setup = () => {
  return render(
    <NoteForm
      notes={[]}
      createNoteCallback={createNoteCallback}
      setFormDataCallback={setFormDataCallback}
      formData={formData}
    />
  );
};

test("should display a create note button", () => {
  const { getByTestId } = setup();
  const button = getByTestId("note-form-submit");

  expect(button.props.children[0].props.children.props.children).toBe(
    "Create Note"
  );
});
  • Run all the tests

  • Green

  • Add another NoteForm test

test("should display the name placeholder", () => {
  const { getByPlaceholderText } = setup();
  const input = getByPlaceholderText("Note Name");

  expect(input).toBeTruthy();
});
  • Run all the tests

  • Red

  • Update the input with the placeholder

<TextInput
  testID="note-name-field"
  onChangeText={(text) =>
    props.setFormData({
      ...props.formData,
      name: text,
    })
  }
  placeholder="Note Name"
  value={props.formData.name}
/>
  • Run all the tests

  • Green

  • Add another NoteForm test

test("should display the description placeholder", () => {
  const { getByPlaceholderText } = setup();
  const input = getByPlaceholderText("Note Description");

  expect(input).toBeTruthy();
});
  • Run all the tests

  • Red

  • Update the input with the placeholder

<TextInput
  testID="note-description-field"
  onChangeText={(text) =>
    props.setFormData({
      ...props.formData,
      description: text,
    })
  }
  placeholder="Note Description"
  value={props.formData.description}
/>
  • Run all the tests

  • Green

  • Add another NoteForm test

test('should require name and description', () => {
    formData.name = "";
    formData.description = "";
    const { getByTestId } = setup();

    const button = getByTestId('note-form-submit');

    fireEvent.press(button)

    expect(createNoteCallback.mock.calls.length).toBe(0);
});
  • Run all the tests

  • Red

  • Add validation for name and description

function NoteForm(props) {

  function createNote() {
      if (!props.formData.name || !props.formData.description) return;
      props.createNote();
      props.setFormData({name: '', description: ''});
  }
  
  return (
    <View>
        ...
        <Button testID="note-form-submit" 
            title="Create Note" 
            onPress={createNote}/>
    </View>
  );
}
...
function App() {
  ...

  async function createNote() {
    const newNote = await save(formData);
    const updatedNoteList = [ ...notes, newNote ];
    setNotes(updatedNoteList); 
  }

  ...

  return (
    ...
  );
}
...
  • I moved the form name and description reset to the NoteForm component to keep reset with the fields that it resets.

  • Run all the tests

  • Green

  • Add another NoteForm test

test('should require name when description provided', () => {
    formData.name = "";
    formData.description = "test description";
    const { getByTestId } = setup();

    const button = getByTestId('note-form-submit');

    fireEvent.press(button)

    expect(createNoteCallback.mock.calls.length).toBe(0);
});
  • Run all the tests

  • Green

  • Add another NoteForm test

test('should require description when name provided', () => {
    formData.name = "test name";
    formData.description = "";
    const { getByTestId } = setup();

    const button = getByTestId('note-form-submit');

    fireEvent.press(button)

    expect(createNoteCallback.mock.calls.length).toBe(0);
});
  • Run all the tests

  • Green

  • Add another NoteForm test

test('should add a new note when name and description are provided', () => {
    formData.name = "test name";
    formData.description = "test description";
    const { getByTestId } = setup();

    const button = getByTestId('note-form-submit');

    fireEvent.press(button)

    expect(createNoteCallback.mock.calls.length).toBe(1);
});
  • Run all the tests

  • Green

  • Add another NoteForm test

test('should add a new note when name and description are provided', () => {
    formData.name = "test name";
    formData.description = "test description";
    const { getByTestId } = setup();

    const button = getByTestId('note-form-submit');

    fireEvent.press(button)

    expect(setFormDataCallback).toHaveBeenCalledWith({name: '', description: ''});
});
  • Run all the tests
  • Green
  • Commit

Code for this section

Demonstrate The Native Mobile App

Demonstrate The Native Mobile App

Up until now we have been using Expos Web view to test drive this app.

I installed the iPhone Expo App from the Apple Store. I used my iPhone camera to scan the QR Code provided in the Metro Bundler.

  • I entered a new note through my iPhone
  • I opened the Amplify web app that I deployed in the first tutorial.
  • I refreshed the web app and verified that the new note is listed.
  • I added a note through the web but it did not list the new note on the mobile app
Given a new note was entered outside of the mobile app
When I pull down to refresh
Then the new note is listed
  • This is a new user story that came out of the mobile native app demo.
  • In order to test drive this we will set up the auto refresh in the NoteList component so we can pass in the function and the refresh interval.
...
const fetchNotesCallback = jest.fn();

const defaultProps = { 
    notes: [],
    deleteNoteCallback: deleteNoteCallback,
    fetchNotesCallback: fetchNotesCallback,
    interval: 1
 };
  
...

test('should reload the note list on the specified interval', () => {
    const oneMillisecond = 1
    setup({interval: oneMillisecond});

    expect(fetchNotesCallback.mock.calls.length > 1).toBe(true);
});
  • Run all the tests
  • Red
useEffect(() => {
  const interval = setInterval(() => { props.fetchNotesCallback() }, props.interval);
  return () => clearInterval(interval);
}, []);
  • The useEffect React hook is called during the loading of the page by React Native.
  • Run all the tests
  • Green
  • Commit

While fetching new notes every second is one way to solve this problem, this is a very expensive way to solve this problem. Imagine 1000 users were calling your GraphQL API every second all day long (~86 million calls). For a production application it would be better to push changes out to each user. DynamoDB Streams enable this functionality. It's outside the scope of this tutorial to set this up.

Code for this section

Styling The App

Styling The App

Now let's use React Native Elements toolkit to improve the Note Application's look-and-feel.

  • Run npm install react-native-elements

  • Run npm install react-native-safe-area-context

  • Add padding to the top level View component in App.js

...
<View style={{padding: 20}}>
  ...
</View>
...
  • In the Header component, switch the import of the Text component from react-native to react-native-elements and add h1 to the new Text component.
...
import { Text } from 'react-native-elements';

function Header() {
  
  return (
    <Text testID="note-header" h1>My Notes App</Text>
  );
}
...
  • In the NoteForm component, switch to use react-native-elements. The component TextInput is replaced with Input.
...
import {View } from 'react-native';
import {Input, Button } from 'react-native-elements';
...
  
  return (
    <View>
        <Input testID="note-name-field" 
        ...

        <Input testID="note-description-field" 
        ...

        <Button testID="note-form-submit" 
        ...
    </View>
  );
}
...
  • In the NoteList component, switch to use react-native-elements. The notes use a Card style similar to what was used in the first tutorial. The notes also use a ScrollView in order for users to view their entire list of notes.
...
import {SafeAreaView, ScrollView,} from 'react-native';
import {Card, Button, Text, ListItem} from 'react-native-elements';

function NoteList(props) {
...
  return (
    <SafeAreaView style={{flex: 1}}>
      <ScrollView>
        {
            props.notes.map((note, index) => (
              <ListItem key={index}>
                  <Card containerStyle={{flex: 1}}>
                      <Card.Title testID={"test-name-" + index}>{note.name}</Card.Title>
                      <Card.Divider/>
                      <Text testID={"test-description-" + index}>{note.description}</Text>
                      <Button testID={"test-button-" + index }  
                        style={{padding: 10}}
                        onPress={() => props.deleteNoteCallback(note.id)}
                        title="Delete note"/>
                  </Card>
                </ListItem>
            ))
        }
        </ScrollView>
      </SafeAreaView>
  );
}
...
  • Run all the tests

  • Red

  • The structure of the NoteForm component changed so one of the component test is breaking. Update the test to point to the new location.

test('should display a create note button', () => {
    const { getByTestId } = setup();
    const button = getByTestId('note-form-submit')
    
    expect(button.props.children[0].props.children[2].props.children).toBe('Create Note')
});
  • Run all the tests
  • Green
  • Commit

Code for this section

Customer Feedback

Customer Feedback

Be sure to have a regular time set up with your customers to demonstrate your working application. Also release an MVP as soon as possible. Let your users install the app on their devices and start using it.

As our users started to use the app they provided the following requested user story.

Given that a valid name and description was entered
When I Create the Note
Then focus my cursor on the Note Name field
Name Field Focus

Name Field Focus

Based on customer feedback let's test drive focus on the name input after a note is created.

it('should create a note when name and description provided', () => {
    ...
    cy.get('[data-testid=note-form-submit]').click();

    cy.focused().should('have.attr', 'data-testid', 'note-name-field');

    ...
});
  • Run all test
  • Red
import React, { useRef } from 'react';
...

function NoteForm(props) {

  const noteName = useRef(null);

  function createNote() {
      ...
      noteName.current.focus();
  }
  
  return (
    <View>
        <Input testID="note-name-field" 
            onChangeText={text => props.setFormData({ 
            ...props.formData, 'name': text}
            )}
            placeholder="Note Name"
            ref={noteName}
            value={props.formData.name}/>

        ...
    </View>
  );
}
...
  • The useRef hook provides a reference that you can register on the ref attribute. Then in the on submit callback add the call to set the focus on the referenced input.

  • Run all the tests

  • Green

  • Commit

Code for this section

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Contributors 2

  •  
  •