字幕列表 影片播放 列印英文字幕 [MUSIC PLAYING] JORDAN HAYASHI: Hello, and welcome for lecture 12, deploying and testing. So last week, we talked about performance. We talked about the trade-offs that you get when you do some performance optimization, that just being additional complexity. We talked about a couple of different ways to test against performance, one being the React Native perf monitors. So it's built into your phone, and it allows you to see how many frames per second you run. We talked about the Chrome Performance Profiler, which actually runs your JavaScript code within Chrome and shows you a flame chart of all of the components that are rendering. We talked about a few different common inefficiencies that we see in React. One would be re-rendering too often, another being unnecessarily changing props, which may actually contribute to re-rendering too often. And then lastly, just unnecessary logic. And then we ended the lecture with a demo of this animated API, which allows us to run some calculations on the native side of things rather than within JavaScript. So this week, we'll give a high-level talk about deploying apps. So the way that you deploy in Expo is by building the app locally, and then uploading it to the store. Before you do that, you have to make sure to set the correct metadata in your app.json file. And you can find out exactly what those are at this Expo documentation link here. Then you go ahead and build the app using this command-line utility called exp. It's basically just an alternative to the XDE because the XDE does not allow you to build the app within that GUI. The way to do this is to install the script. So you do npm install global exp, just like how you install a bunch of other libraries. You then build it by doing either build colon iOS or exp build android. What that does is it actually sends your JavaScript-- your whole bundle, actually-- over to Expo's servers, where it does the entire build. And then Expo uploads that build to S3. The way that you get that build onto your computer is to run this [? ex ?] [? build ?] [? status, ?] which if you run it while it's building, it will let you know it's still building. But once it's done, it will spit out a URL for you, which is the link to your app in Amazon's S3 storage system. And then you can just cut and paste that into a browser and go ahead and download that file. Then you can upload it to the appropriate store. So the way that you do that depends on whether you're doing iOS or Android. And here's a couple of documentation links that talk you through that process. One great thing that you can do with Expo is to deploy new JavaScript by republishing. And that's called over-the-air update. So you actually upload a new bundle because the way that your app works, it's really just a shell that's running JavaScript code. And so you can go ahead and download some new JavaScript from the web and run that as your application. And so by republishing from the XDE or using that exp script, you can deploy a new JavaScript bundle and push it over the air to your clients without having them redownload the app from the app store itself. Though if you want to change any application metadata, like the name of it, then you're going to have to resubmit it to the store. So doing this over-the-air update thing is very, very powerful, but also slightly dangerous because you can actually upload JavaScript that crashes your app and doesn't work. And it won't actually be caught by their respective app store people because they never see it. It just goes straight to the cloud. And then your users go ahead and download that. And so how do we ensure that everything works as expected before deploying? So you do that by testing. And so when we use the word "testing," we generally are referring to automated testing as opposed to manual testing or doing quality assurance, whereby you just open up your app manually and play with all of the features that you have to make sure that they work appropriately. And so why might we want to do this? Well, as your application grows in size, manual testing gets more and more difficult. And more complexity means there are more points of failure. And so if you imagine if every single week, you add 10 more features, that's growing linearly every week. And so even though you keep adding the same number of features every week, the amount that you have to test every week actually goes up by the integral of that. So you have a linear growth in the number of features that you have to test every single week. So by adding a test suite, you can ensure that you catch all of your bugs before they get shipped. Because the last thing that you want to do is actually upload some broken code, because then all of your users will get a broken app. But how do we know which parts of our app to test? There's this theoretical structure called the test pyramid. And it determines a methodology for determining test scale or granularity. So at the very bottom of the pyramid is what's called a unit test. And this tests an individual unit of code. And generally, that's either a function or a class or a method. Then you have integration or service tests, which allow you to test the integration of multiple of these pieces of code and how they work together. And this is completely independent of the UI. Which brings us to the last one, the UI tests, or end to end. And this allows us to test a feature thoroughly, including the UI and network calls and everything. So it's basically from one end of your code all the way to the other, basically what a user would be doing themselves. So how are we going to write some unit tests? And so as we talked about in the last slide, this is testing an individual unit of code, like a function or a class or a method. One great thing about unit tests is they're very, very granular. So you know exactly where your code is breaking. And that makes it very easy to tell what you need to go fix. And so the very most basic unit test is just a function that lets you know when any behavior is unexpected. And so let's go ahead and write our first unit test. So I'm going to make a new directory called testing. And inside that, we're going to go ahead and write our first test. And so let's write a very simple function, maybe something like sum. And what sum does is it's just a function that takes a couple of numbers. Let's do function sum. That takes an x and a y. And let's just return the sum of those two numbers. So return x plus y. So pretty simple. I'm pretty confident that this code does what I think it does. Let's go ahead and write some tests anyway. And so how might we go about doing that? Well, we could write a function that does that. Or we could just do console.assert, which is basically saying, I'm going to assert that this thing is true, and if it's not true, then throw an error. And so let's just assert that sum of 1 and 1 gives us 2. And if it doesn't, then we can throw an error that says error summing 1 and 1. So we can save this and run it using Node. And we get no output, which is a good thing because if there's no error, it means our assert passed. And so let's see what this looks like if we had written assert that doesn't pass. And so say we asserted that 1 and 1 was actually 3. And let me go ahead and quiet this ESLint. So if my now assert that sum of 1 and 1 is 3, then when we run this, we get an assertion error. It says assertion error. Error summing 1 and 1. And so we just effectively wrote our very first test. So let's first fix this and make sure it passes and maybe write a couple other tests. We can do console.assert that summing 0 and 0 gives us 0. And maybe a last one that summing 20 and 30 gives us 50. 20 and 30. And we can just sanity check to make sure this actually works. And it does. Great. So we just wrote our first simple test. And so one thing that you may notice is that we have our tests directly in the same file as our actual function. And so if we were to write something like add with an x and y-- or not add, but maybe multiply, now if we want to add a test to this, we're going to have to add some console.asserts. And suddenly, this single file starts to get a little bit noisy. And so just like we've done in other examples, we might want to start breaking out things into separate files. And so maybe the test should not be in the same exact file as the functions. Maybe we should add it to its own test file. And so let's go ahead and do that here. So let's save that, and then create a new file called sum.test.js. And let's just move all of our asserts over to this file. And you may notice that if I try to run that sum.test.js file, it's going to error because sum is not actually defined. And so we're going to have to bring that function called sum from sum.js into its test file. And since we're working in Node, we'll go ahead and use that require statement. So const sum equals require ./sum.js. And then just make sure that in test.js or in sum.js, we need to just make sure that we set our module's exports to be sum. And so this is basically the same way of doing export default in ES6 is just the way of writing it in Node, since Node doesn't yet support import and export syntax. So let's just do module.exports equals sum. And then now we can try to do node sum.test.js, and no errors. Great. So what happens if we change sum.js? Maybe we actually add a bug where rather than doing x plus y, we do x plus y plus y. Now if we run our tests, we see that we get an error summing 1 and 1. But it doesn't run the rest of our tests. It just stops at the first error. And so that's a little bit of a downside of the way that we've currently been implementing our tests. And so there are these things called testing frameworks that give you additional benefits. One of them is that they run all of the tests instead of just failing on the first error, like we just saw. Another great thing is it gives you pretty output because currently, we just get this assertion error, which is informative, but it doesn't really look all that great. It also has this cool benefit where it can automatically watch for any changes in our application. So as we change our logic, the test can run automatically without us having to remember to run them before even checking them in or sending them in a new deploy. And lastly, they give you these things called mock functions, which we'll take a look at it in a little bit. So the testing framework that we're going to be looking at this lecture is called Jest. And so if you look up Jest, what you see is this thing called Delightful JavaScript Testing, which is a bold claim. But we'll see that it is pretty great to work with. So this is a testing framework written by our friends at Facebook, so the same people who wrote React Native. And the way to install it is doing npm install --save-dev jest. And so just to do that here, we can do npm install dev jest. So there's a shorthand to just do npm i -d rather than npm install --save-dev. And so this goes ahead and installs Jest. And as it's installing, the way to run it is doing npx jest. Make sure if you want to use the npx command, it's built into NPM 5 and above. And I believe can install it otherwise. But in NPM version 5 and above, it's automatically bundled. But the easier way to run it is just by adding a script to your package.json. And so just like in earlier examples, we added something to package.json, which might be, like, linting. We can also add something like test, where we say if we run npm run test, it's just going to run Jest. Jest. And so now we can run npx jest, and we see that it does run. It actually ran our sum.test.js file because it's smart enough to know that anything something.test.js, it should just run. Or we could run npm run test, and it will do the exact same thing. There's actually shorthand for this. We can do npm test. Or even shorter hand, we can do npm t. So all of those four commands all do the same exact thing. So the way that this works is it will automatically find and run any files that end in .test.js or any other regex expression that you specify. But for now, we're just going to be using that .test.js. And so now let's rewrite our sum.test.js. Rather than using console.assert, let's go ahead and use Jest. So the way that we do this is we just replace that console.assert with jest. And so the syntax for that is to define a test. So you just do test, and then a string of what you're actually testing here. And so let's do-- let's test that sums 1 and 1. And now we have a test for summing 1 and 1. We can do the same thing for sums 0 and 0, and lastly, 20 and 30. And if we now run this, we see that it will find the tests. And it just skips all of them because we didn't actually specify what they were supposed to do. But it went ahead and found all three of the tests. And so now let's go ahead and define what those tests should actually do. So how are we going to define this test summing 1 and 1? So first, let me go ahead and let ESLint know that there are a few global functions called test. So now we can say, how are we going to test that we correctly sum 1 and 1? And so the way to do that is to pass a callback. And we can say we're going to expect that if we sum 1 and 1, it should be 2. And so the syntax for doing that in Jest is you can do expect something-- so the expression here is 1 and 1.toBe, and then whatever we expect it to be. So in this case, it will just be 2. So we can save that, and then let ESLint know that there are a couple other globals called expect. And then now if we run this, we can see that it finds it, and it runs here. We expected it to be 2. We got 3. Why is that? Because a while back, in order to show that we can fail a test, we actually changed our implementation of sum. And so we should probably fix that to make it correct. So great. Our test caught an illogical bug. So now let's go ahead and run npm test again, and we'll see that now it passes. And it gives us a nice little green check mark next to our sums 1 and 1 test. So let's go ahead and add the rest of our tests. So here, we expect this to have a value, as well. So we can say expect sum 0 and 0 to be 0. And lastly, summing 20 and 30, we expect that if we sum 20 and 30, we expect that to be 50. And so now our tests are actually a little bit more readable. You can basically just read the code, and you have the tests. So test that it sums 1 and 1. How do we do that? Well, we expect the sum of 1 and 1 to be 2. So it reads almost like English. So let's go ahead and run all of these. And we see that all three pass here. And so I talked about how Jest can automatically watch all these files. But I haven't been doing it. I've been quitting my file, then rerunning npm test. And so let's also add a script that allows us to watch the files. So the way to do that is we can just do npm test, and then pass it a command-line flag called watch. And we'll go ahead and run jest watch for us. And now it'll run it. Let me first quit and create a separate pane. So if I do npm test and tell it to watch, then it will start watching. And then I can flip over into a new pane, open up sum.test.js. And if I add a new test-- let's do 20 and 22. And we expect that to be 42. I'll save, and I'll hop over to the new frame. And we see that it already ran that test for us automatically. And so it's watching all of our files, and anytime one of them changes, it will run that associated test file. And maybe we don't want to have to run npm test --watch every single time we want to do that. We can just add another script to our package.json. So if I add a new script here that is test watch-- I can name this whatever I want. I'm just going to call it test watch. We can just run jest --watch. And it'll do that for me. And so you see here that we have jest with a single flag, --watch. The reason that we had to do two flags above like this was this would let us let NPM know that it should pass the next flag to whatever script it ends up running. But now we can just run npm run test watch. And it does that for us. Great. So let's forge ahead and actually test some things that we've been using thus far. So let's go ahead and test our Redux actions. And so we can replace any of our tests that we have prior, which we don't have any yet, with expect to be and to equal. And so let's go ahead and write some tests for our Redux actions. So rather than writing in a dummy sum file, let's actually open up our Redux, our actual actions, and start writing tests for these actions. And so if you remember from prior weeks, we have a couple action creators that are functions that take an update and return a type and a payload in the case of update user. In the case of add contact, it takes a new contact and returns an object or an action with a type and a payload. And then lastly, we have an async action creator that does a bunch of additional things. So let's go ahead and write a few tests for our action creators. So let's first open actions.test.js file. And now let's write some tests for our Redux actions. And so what's the first one that we want to test? So let's just remember which exist. So the first one we're going to test is the simple update user. And so let's go ahead and first import. Let's just import all of our actions. So import everything as an object called actions from this file called actions. And then let's do some testing. So let's do test that update user returns the correct an action. And how are we going to do that? Well, it's just a function. Let's invoke update user. So let's expect that if we invoke update user, and what are we going to pass? Let's just pass the name should be updated to test name. And what do we expect that to be? Well, we expect it to be an object that has a type of actions.update user and a payload of name test name. And we're missing parentheses there. Great. So let's run this test and see what happens. So if we run it, it will let us know something interesting. Well, one, the updateUser's not defined because it should be actions.update user. So since we're importing all of the actions as an object called actions, if we want to use updateUser, we need to do actions.updateUser. And while we're at it, let's let ESLint know that there are a few globals called test and expect. And now let's rerun those tests. And now it will tell us something interesting. It says we expected the value to be this object. And instead, we received this object. And you might notice that these two objects are exactly the same, character for character. And so why might it have errored? So if you remember back to an early lecture, we talked about how to compare objects. And the way if you compare objects with triple equals, what does it do? It doesn't compare the key value, all of those values within the object. It actually just checks if the two objects are being referenced in the same location because anything that is a non-primitive is stored via reference. And so since we created a new object in our .toBe in our test file, we see, oh, object is equality. They're not referencing the exact same object. And it actually lets us know, hey, the compared values have no visual difference, meaning they're basically the same object here. Looks like you probably wanted to test for the object array equality, which is-- we should probably use toEqual, which actually does that check and will check the key-value pairs rather than just checking the reference. And so let's go ahead and update our code so that it's actually correctly checking what we want it to check. So let's now do actions.test.js. And rather than using .toBe here, let's use toEqual. And now let's run npm test. And we should see that now it actually does indeed pass. Great. So let's add a few more tests. Maybe let's pass a different name here. Maybe we'll update a phone number. And let's make it some bogus phone number. And then we want to change the payload here to be that phone. And what do you notice that I'm doing right here? It's interesting because I'm almost reimplementing this action, right? My test is basically the exact same logic as the actual actions file. Because our updateUser takes an update and returns an object with that type and payload. And down here, we're doing almost the exact same thing. We're doing an action with this and checking against an object with a type and a payload. And so we're almost word for word just reimplementing that exact function, which isn't really great in terms of testing because if we're testing a function by running a function that we implemented in the exact same way, if there's a bug in the first function, of course there's going to be a bug in the test function. And so we won't actually catch any bugs that we might have intended to catch. And so what might be a better way to test this? Well, it turns out there's this thing called snapshots, which compares an output of a function in the past to what we get now, which is good because now we get notified if the output changes, which is really what we want when we test these action creators. Because since they're really simple and just returning a new object, we just want to make sure that it's returning what we expect it to return. And so then if we end up changing something later, we get notified that something changes, but we don't actually have to rewrite the test if that change was actually intended. And so let's actually refactor our test to rather than mirroring the logic in our actions, to, rather, use a snapshot. So currently, we're doing expect this to equal, and then hard coding exactly what we know is going to come out. But maybe instead, we should do toMatch the snapshot. And we can go ahead and delete that. And so if we now save that and run npm test, we see that the snapshot was saved. And now let's change it to see if the snapshot-- if we're notified, as expected. So let's change our actions file. And maybe we'll send a type that's updateUser, a payload that's update, and also-- we wouldn't want to do this in real life. But maybe we'll just add a debug flag that says false. So something completely useless, but it is in fact changing our implementation. And so now if we run npm test, we'll see, uh-oh. One of our snapshot tests failed. Let's see what happened. In our actions.test.js, we see that update in our test called updateUser returns an action. We see that the received value does not match the stored snapshot. And it'll actually tell us exactly what changed. It says that the snapshot received something new. That's what this plus received is. And what it received that was new was this debug false, which wasn't in the original snapshot, which is great because maybe we didn't want to change that. It's just letting us know that it changed. And so how do we let Jest know, hey, we meant for that to happen? Update your snapshot. Well, it tells us inspect your changes or run npm test -- -u to update it. And so we can just run npm test -- -u. And it will go ahead and update the snapshot in our test suite. And so let's go ahead and revert our change. And now again, if we test, what do we expect to happen? Well, we changed something. So the snapshot test should fail. If we go read what failed, it says, oh, now this is minus. It's gone. And we expected it to be there. We can that Jest know, oh, by the way, we meant for that to happen. So -u. Update your snapshots accordingly. And now if we run npm test after updating, everything passes, and all is good in the world. Great. So now let's add a few more tests just to make sure everything's working as expected. And so maybe we want to test the case where updateUser returns an action when passed empty object. And we can just cut and paste this. And rather than passing an object here, we can just pass an empty object. And we expect it to match whatever the snapshot was. And so maybe we also want to test a case where you pass an empty name. And so let's do name is empty. And so now you're starting to see a bunch of very similar tests. And maybe there's a better way to go ahead and group them together. And it turns out there is. You can describe a group of actions, and Jest will automatically group them for you. And so the way to do that is-- let's first look at the output of this. It just lets us know that some tests are passing. But maybe let's add a logical group of tests together. So let's do actions.test.js and group these very similar tests together. And so now let's describe a group of tests. And so this one is updateUser returns actions. And it takes a callback, just like the other tests. And now we can group these things together. And rather than using test, we do it. And what does it do? It returns an action, or it handles an empty object or it handles an empty name. And now if we run these tests, we see that there-- well, they have obsolete snapshots because we updated the tests. So we'll go ahead and first update all of these things. And now it works. But it's not giving me the pretty output. Let me see why not. Oh, we should run our watch mode. Hmm. It's not giving us the pretty output. And I will look into it at the break. But this is a way of joining very similar tests together in logical groups. And we should let ESLint know that we added a few more globally available variables, including describe and it. Great. So now let's take a look at trying to test a more complicated action. So what happens if we want to test this async action that we wrote? It's a lot more complicated than the simple actions that just took a single argument and immediately returned a new object. This one's doing a few different other things. And so how might we go ahead and do this? Well, one, it's async. So that might be a bit of a difficulty. So it turns out-- oh, so which of these functions should we use? If you're using primitives, then you can use toBe because as we learned in an earlier lecture, you can just check primitives with triple equals. And it'll go ahead and see if those values are equivalent. If you're doing objects and you don't expect those objects to ever change, you might want to use toEqual like we did in the earlier example because it will do a deeper equality check. But that has the downside that it will actually error if we end up changing the objects. And we'll have to go back and rewrite those tests. If we have objects that might change in the future, that's when we should use something like a snapshot because then it will give us the benefits of knowing that we changed our function. But if that was a change that we were OK with or a change that we intended, then we don't have to go back and rewrite all of our tests like we would have if we were using that toEqual. So now let's take a look at some asynchronous actions. And so why might this be more difficult? Well, it adds a few different difficulties, it seems. So we have to wait for the results to come back before we check against the results. So that might be a little bit tricky. Our tests also might rely on libraries, other libraries that we go ahead and import. And lastly, what about external services? And so in our login user, we're actually awaiting login, which sends a fetch request to an external service. So how might we go ahead and combat these three difficulties? Well, for the first one, if we return a promise, Jest is smart enough to be able to wait for it to resolve before it checks against that value. Jest also supports async await, which is awesome because we can just await a value, and then go and check against it. And so let's go ahead and start to write a test for this asynchronous action created here. And so we're going to be using logInUser And let's describe a group of tests to see if logInUser returns the actions that we want it to. So first, logInUser should do what? It should dispatch an action that the login is sent. Then it's going to try to do something by awaiting a login. And then it's going to dispatch something else. And then if there's an error anywhere up there, it's going to dispatch something else again. And so it might be a little bit difficult to track what's going on. So let's first just write some dummy function dispatch that doesn't actually do anything. Then, if we wanted, we could await logInUser. But what happens then? If we run this, we'll see that-- uh-oh. Await is a reserved word. So what do we have to do if we're ever going to use that await key word? Well, first we need to make sure to let JavaScript know that hey, this is not a normal function. This is an async function. And so now we can invoke logInUser with whatever we want-- so the user name and password-- and then also pass it dispatch. And then we expect it to error here. And I'm just going to wave my hand at that case for now. So right now, for this function called dispatch, we don't really know what to do with this. In our actual application, what happens in this particular action? Well, we have that Redux [? flunk ?] middleware, which handles passing that dispatch function in for us and will dispatch those actions to the store on our behalf. But in our particular unit test here, we don't really have access to that store. We don't want to rely on these external libraries in order to run this code here, which is that second difficulty here. And so how might we go about that? Right now, we just wrote a dummy function. But certainly there's a better way to do this. Well, it turns out Jest supports doing what are called mock functions, which is similar to what we're doing, but these mock functions actually do something. They will track what they're invoked with or invoked on. And so we can actually use this mock to be able to figure out exactly what actions are being dispatched because if you remember the way that this action works, first it'll dispatch an action that's letting the Redux store know that we have sent off this login request. Then it's going to go ahead and do it. And depending on what happens, it's going to dispatch other actions. And so if we mock this dispatch function, we can go ahead and check exactly what values the dispatch is invoked with. So how might we do that? So rather than-- oh, this should be actions.logInUser. Rather than just writing dispatch as a dummy function that we wrote here, let's actually use jest.function-- .fn, I believe-- which creates one of those Jest mock functions. And so now we can go ahead and use that to track exactly what happens. So let's do it. So let's test that it dispatches the login sent action correctly. And how are we going to test for that? Well, first we're going to create that dummy function, that mock function by Jest. Then let's go ahead and kick off the action here. And so let's just write mockDispatch so that we can remember what that is. So great. We created a mock dispatch function. We go ahead and pass it in as the mock dispatch in our login actions. And how are we going to check to see exactly what happened? Well, it turns out attached to mock dispatch, we can see all of the things that it was invoked on. So first let me fix this. This should be an async function. And this no longer needs to be an async function. So how might we want to check to see what the mock dispatch was called on? Well, it turns out we can check. We can see mockDispatch-- let me check the documentation really quick-- .mock.calls. And what that does is it's all of the calls that the mock function was invoked on. So that's really helpful. Why is that helpful? Well, we can just see if that dispatch was invoked with the action that we were expecting it to be invoked with. So let's just console log that for now. And let's also let ESLint know that jest is globally available. So now let's run npm test. And we see what? We see that login was sent, which is great. We expected dispatch to be invoked with login sent. And then we also see that login is then later rejected because fetch is not defined, which is a problem. But for now, we're only focusing on whether or not the login sent was done successfully. So let's finish that test to actually check for that. So rather than console.logging the mockDispatch.mock.calls, we can actually expect it to be some value. So let's expect mockDispatch.mock.calls. We should probably look at the first time it was called, which is indexing into that first array. And let's also look at the first value or the first argument that it was invoked with. And so let's index into that. And we expect it to be something, right? We expect it to be an object that has a login sent value as the type. And what's going to happen when we run this? Does anybody know? We get that same error that we got earlier. We see that these two objects are the same. But the test is still failing. And it's the same reason why it failed earlier. The two values have no visual difference, but we are checking their reference. And since we just created that second object, obviously those references aren't going to match. And so rather than using toBe, what should we do instead? Well, we should probably use that .toEqual. And now we see that those do, in fact, pass. Great. But that wasn't really the difficult part, right? It's pretty easy to see in our code that that's exactly what's going to happen. Really, the hard part is to make sure that this does what we expect it to do. But since login is defined within the logInUser, that means we have to rely on that to work. And we don't really want our unit tests to rely on things that are way outside our control, in this case an API network request that's being sent out. So how might we get around that problem? It's actually not an easy one to solve. There's actually a strategy called dependency injection, which we can go ahead and use to get around this problem. And so what dependency injection is is that we pass functions on which other functions rely on as arguments to those functions. So we make these functions even more [? pure. ?] And so everything that they need, they receive as arguments. And how does that help us? Well, it allows us to mock the functions that rely on external services, which is pretty nifty. So rather than using login here, we can make this [? pure ?] by taking the login function as an argument in our logInUser. But what did we just do? We changed the-- well, we didn't actually change anything yet. If we use it here, now we've gone and changed the way that you're supposed to use the logInUser function, which means we broke backwards compatibility, which means every single time we use this logInUser function in our entire app, we now have to go and fix, which is not really fun. So how might we get around that? So what happens every time, currently in our app, we want to use logInUser? Right now, we pass two arguments, username and password. And in no places do we pass a third argument because we weren't supposed to. And so if we know that we're never going to pass a third argument, we can have some sort of default argument that it falls back on. And so one thing that we could do is we could do const login or const realLoginFn is either the login function if it's passed-- so if so. Otherwise, the login that we imported at the top of the file here. Or if we want to use some more shorthand, we can do login or login. Or we can rely on a JavaScript feature called default function values. So if nothing is passed as the argument, a third argument here, we can have a default value be login, which means expect to get a username and a password in the login function. And if you receive undefined for this third value, then use this default value instead. And now we've added dependency injection, but we haven't actually changed our logInUser function. Because everywhere that we use it in our app, we pass two arguments. And this login function will always fall back to the default, which is the login function which we imported up here. And so that will always be used here. And so it does exactly what it did before. But now in our tests, we can go ahead and add a mock function for login. So let's go ahead and do that. So now we have a test.test to see if it dispatches login sent. We can add now a test with correct credentials. So now let's make sure that if we pass the correct credentials, we get in. We get the login fulfilled. So how are we going to do that? Well, we should probably have a mock dispatch. But we need another mock function. Or do we? So the special thing about the Jest mock functions here is that they can track whatever they were passed in. And we can reference that later. But does that really help us in our logInUser example? Let's check back to see what happened here. So for our login function down here, all that we're doing is we're extracting a token from it. And so either it works and we receive a token, or it doesn't work, and supposedly an error is thrown, which we then catch down here. And so we don't really need to mock a function using Jest here because we don't really care what username and password were passed in. And we don't care about the history going back. We really only care about whether this login function returns a token, or whether it throws an error. And so rather than using the jest.function, we don't need to use the mock function. Instead, we can actually just define a function ourselves. And so let's do that. Let's do const login is a function that takes a username and a password. And what happens? So if the username is username, or we can just-- do whatever we want. We can say if the user name is u and the password is p, then what do we want to do? We want to return a token. So this is a test token. Otherwise, what do you want to do? Well, it should throw an error, right? So we can throw new error. Incorrect credentials. So let's clean this up. So now we have a mock dispatch function. And we also have now a mock login function. And when I say mock, it's not actually a Jest mock function. It's just a function that we defined. And so now we can actually test this. We can say const token is await login username and passwords. So rather than writing that out, let's just do expect that if we invoke the actions.logInUser-- let's actually just do exactly what we did up here. So let's await actions.logInUser. Let's invoke with the username and password credentials that we know are correct, u and p. So let's do u here and p here. And then what do we also have to pass into this function? Well, we need to pass in the login function that it should be using. And so we just wrote it here. So let's also pass in the login. And let's just call it mock login so we know it's a mock. And then we also passing our mock dispatch. And then what do we do? Well, now what? We expect that the mock dispatch has now dispatched more than one things. One should be-- so mockDispatch.mock.calls. So if we do the first one, what's not going to be? What is the first thing that our logInUser action dispatches? Well, if we refer to our actions.js file, we see that always the first thing that gets dispatched is that login set. But now for this particular test, since we're testing that the login fulfilled action gets dispatched, now we're interested in what's dispatched second. And so let's go ahead and actually match against the second one. And we can do .toEqual that action. Or, what's better is we can just do to match the snapshot. Well, maybe we should do both because for the first time, we're not really guaranteed that it's actually going to be what we want it to be. So let's also expect that the first one is going to equal the type of login filled with a payload that contains a token, which is thisIsATestToken. And now, assuming there are no syntax errors, that should be what we expect it to be. So let's go ahead and just run these tests. And await is a reserved word. What does that mean? It means we forgot to use async. So let's go ahead and make the second test also a async. And now we can see whether or not our test passes. It does not because it can't read property two of undefined. Oh, we don't want two here. We want one. I was not zero indexing properly. Because if we want to get the second value in an array, which array index do we actually want to look into? Probably one, right? Great. So now-- oh, there's also a bug. We don't want the second. We don't want the third array index of this. We actually want array index zero. And so now, hopefully this passes. And we see an error. It might just be the wrong shape. Yeah, the payload is just the string rather than being an object with a token in the string, which might actually be a bug if we want to follow a very strict shape in our flux action. But let's actually just write the tests to fit the function for now, since we're already using that function successfully. But our test actually just kind of caught a bug for us, which is cool. So let's go ahead and just update our test so that it passes. So rather than having the payload be an object with a key called token, we just pass in the token as the payload. And now we'll see that the test passes correctly. Awesome. So let's quickly handle our last case. We want to ensure that we dispatch login rejected if we try to log in with the incorrect credentials. So we can move that mock login function outside so we can use it again. We can also delete this if we wanted to since we know that the snapshot is correct. But it's not that expensive to run. Let's just keep it in there for extra safety. And let's just do it dispatches login rejected. And let's just copy and paste the whole thing. So we want it to dispatch login rejected with incorrect credentials. And so now let's just pass in an empty string, an empty string. We expect now the call not to be type actions login fulfilled, but to be type login rejected. And we want it to match the snapshot. So let's just run the test. Maybe we're passing in the error string, as well. So the payload is incorrect creds because we're passing in the error string. So let's just update that really quick. Because right now, we're only checking to ensure that the first dispatch, or the array index one, or the second dispatch has a type actions login rejected. But it turns out we're also passing in the error message here as the payload. So let's make sure that this is whatever we called up here. And we can actually abstract that out. So let's ensure that the payload is the error message. And while we're at it, we can also not hard code the token here, but also abstract that out. So now we're guaranteed that whatever fake token we return from the mock login on successful is the exact string that we're checking against in the payload, eventually, in our test. So that's just abstracting it out so that we're not hard coding things everywhere. And so now let's run our tests. And hopefully, every single one will pass. Great. So let's take a short break. And when we come back, we'll see how to test some more complicated structures. Hello, and welcome back. So before the break, we were talking about testing and how to test things like simple Redux actions, and also things like asynchronous Redux actions. And a great question came up during break. And it was, hey, how come our tests look very similar to the functions that we've implemented? And is it really testing what it should be testing? Because it's basically just implementing the function that we're testing. And let me show you an explanation. So in our particular logInUser function here, there are really only two possible paths for our code to take. Either the login succeeds and we fulfill that login, or the login fails and the login is rejected. And so you can think of it as a tree of the possible different ways this code can happen, or just a tree of all of the possibilities. And so there were really just two code paths. Either first, you dispatch login sent, and then you dispatch login fulfilled. Or you dispatch login sent, and then you dispatch login rejected. So there are really only two branches. And so in our tests, we're actually testing three different things. We're testing first that we do what the first line here, where we test whether login is sent. And then we test both of the branches. So either this branch is taken, and we fulfill the login, or this branch is taken, and we reject the login. And in this particular test here, we're testing that bottom branch, whether or not we-- oops. I need to scroll down a little bit. So first we either test that the login is sent. So we test the first line of code. And then we test both possible branches. We test either that login fulfilled is reached or login rejected is reached. And so if we want to dig in a little bit into that login rejected, we can see that although it's similar to the code that is written in login user, it's not really reimplementing anything. In login user what we're doing is we're first dispatching an action. And then we're checking the result of our login function. And so we're going to attempt to log in with the username and password. And depending on how that goes, we're either going to take the branch where we fulfill the login, or we take the branch where we reject the login. And so if you look at our logic in our test here, first we create a mock dispatch. We have already created a mock login. But the first thing that we do is we execute some logic where the code branches. And so we try to log in the user using an empty string for both the the username and the password. So we're sending bogus information, hoping that the login gets rejected. And so that's what we're testing down here. Were saying, hey, ensure that the action that gets dispatched is of type login rejected and has a payload of the error message. And just as a sanity check, we're also going to do a match snapshot here. And we can actually remove the first one once we know that the snapshot has the correct action. And so just to reiterate, even though the test looks very similar in the way it's laid out to the function, it actually does test it pretty well because we have one test to do the first line. Then we have two separate tests to test either branch of the possibility where really the only logic lies, whether we login successfully or unsuccessfully. So, yeah, hopefully that addresses the question that came up. And now let's forge ahead. One more thing to note-- I was curious earlier why our tests weren't being enumerated when we run npm test. And it's because if you run npm test-- or if you run Jest with a flag called verbose, that's what triggers all of the tests to be enumerated. And so if we run with that --verbose flag, we then see all of the groupings that we dictated in our test files. And so we see in actions.test.js, we see the first describe block, which describes the update user returning actions. And then for each it block, we see those strings being repeated here. And so the first it was it returns an action. And then it handles an empty object. And then it handles an empty name. And we see that they all executed correct with that green check mark there. We see the second group here. And we also see all of the tests that we specified in sum.test.js. And so these were not grouped using a describe block like these ones. They were just using that test function. And so they show just as separate tests here. And so if we wanted to show that entire enumeration of all the tests every time we run npm test, then we need to change our package.json to ensure that that flag is passed. And so tests. We should --verbose here and also here. So now when you run npm test, we'll see that all of the tests are enumerated for us. Cool. So we talked about a lot of different unit tests and how we would test our actions. And so now let's test something a little bit more involved. So let's test our Redux reducers. So just check our reducers the way that we implemented it before. Let's just remove this to shut up ESLint. So now there are no linting errors, we can see that we have two different reducers here. So one handles all of our contacts. And so our contacts reducer takes the previous state and an action, and depending on what the action does, it returns a new state. So if the action's type is update contact, we add a new contact into the state and return it. And the way that we do that is we use this array spread. So we basically clone the array from the state, and then tack on at the and the action.payload. If the action is not update contact, we just return to state unchanged because we don't really have anything to change. Our user reducer is a little more complicated because there are different types that we want to check against. If it's update user, then we merge the payload in. If it's update contact, we merge this new object where the previous contact is the payload. And similar thing for login fulfilled or login rejected. And if none of these match, then we just return the state unchanged. And how are these exposed to Redux? Well, we have our single reducer, which combines these two. And so the user reducer is responsible for any changes in the user key. And for any changes in the contacts key, it gets passed to that contact reducer. So let's go ahead and create a few tests to make sure that the reducer is doing what we want it to do. So we do this in a reducer.test.js file. The first thing that we want to do is let's disable ESLint so it's easier to read. And let's import the reducer. And let's also import our actions. Cool. So let's just pick a couple things at random to test for. In reality, we'd want to test all of our functionality in reducer. But since we don't have time for all that, let's just check a couple of things. Let's just check contact reducer. If we add a new contact, let's ensure that it makes it into our state. So let's do describe the contact reducer. And it successfully adds new user. And how are we going to check it for that? Well, first we should expect the result of passing in user reducer or the reducer-- so the reducer takes a state and an action. So let's just create a default state for now. So let's have a default state just be-- let's have the user be an empty object and contacts be an empty array. And I say we should pass the reducer in an initial state. So let's pass the default state here. And it should take an action, as well. And so let's just pull in an action creator from our actions file. And I believe it's called add user. And let's add a user with a name of test user and a phone of that and expect it to just match the snapshot-- to match snapshot. This might actually be called add contact. And just to make it a little bit more readable, do that. And now let's also run our tests in watch mode so we can see what happens as we write them. And so we see that the test is written. One snapshot written in one test suite. So now let's change this to make sure the snapshot is what we want it to be. So let's test user, exclamation point. Save that. The snapshot test should fail. Let's go see why it failed. Because our object that was our state now contains the contacts where the array is test user with an exclamation point rather than without. But it looks fine the way it was before. So let's just revert. And so now we know that this snapshot is what we want it to be. Let's also describe the user reducer. And that successfully updates user. So let's expect the user, if we pass in the default state, let's try to update the user. Maybe that's called update user. And just change the name to test user. Let's see what the tests have to say. One snapshot written. We can see exactly what the snapshot is. We can open up the snapshot file. Or we can just change this really quick so that the diff shows. We can see that now the snapshot test fails, as expected, because now the user, the name is test user with an exclamation point. It was fine the way it was before. So let's just revert. And now our test should pass because the snapshot matches the snapshot that it was before. And it does indeed pass. Great. So now we've gone and tested our reducer. Obviously, there's a lot more to test. But since we're running low on time, we'll just leave that untested for now. So now we've basically tested everything in Redux. But we're yet to actually touch any React or React Native. And so now let's look at some integration tests. And so how might we want to go about testing React components? Well, we can use Jest snapshotting. Just like we were snapshotting and looking at any diffs between what changed in the output of the reducers or any of the actions, we can also use the snapshot feature to test the output of React component rendering. And so how are we going to render React outside of the context of our application? Well, it turns out React allows us to do that by using this React test renderer, which is maintained by the React team. It allows us to just render components outside the context of an application. And so the docs are here if you want to look at them. But we'll be using that in the demo. One additional thing is that now we're going to need to configure Jest a little bit more so that it worked with React. It turns out the Expo team has done this for us. Jest Expo has all the configuration we need. And we can just look at the docs here to see how to use that. So let's just take a quick peek. We can see that we need to run npm install jest expo and save dev. So let's go ahead and do that. And while it installs, we can also see that we need to add a little bit to our package.json. We already have a test script. But we need to add a Jest key that let's Jest know to use the configuration preset specified within Jest Expo. So let's just cut and paste this into our package.json. So we can now open up package.json and just add our Jest preset. So now in theory, we are all ready to try our integration tests. And so now let's actually try this thing called test-driven development. And so there's another question that came up during break that was, hey, it looks like we wrote our entire app. And now we're writing test after the fact. Is that what we should be doing? It turns out there's actually a strategy to writing tests and writing code. And it's called test-driven development, whereby the tests drive what you should be implementing. And as long as you know exactly what you want to be implementing, you can write the tests first, and then implement the components or whatever you're implementing such that it fulfills all of the tests. And so the tests are less tests and more the specification. Or in other words, what should this function, or what should this component, be doing? And so let's actually write a simple button component and use test-driven development to do so. So let's just make a components directory. And let's actually write the test file first. So let's do MyButton.test.js. And shut up ESLint for now. And first we're going to want to import MyButton from a file that we haven't yet written. But it'll be eventually written in MyButton. And what do you want to do? Well, first we should describe it. So let's see what happens in MyButton. Well, first, it should render. It should just appear. So let's just test that it renders. And so now we've run into a little bit of difficulty. How do we ensure that it's rendering? So this is actually what React test renderer does. It allows us to render these components outside the app to make sure they actually exist. So let's go ahead and do that. So let's do import render from react-test-render. And we can do const button is render.create. And what do we want to create? Well, we just want to do a MyButton. And maybe we should turn into JSON so it's easy to match against. Just do .toJSON. And then let's just make sure that it matches the snapshot that it was before. So let's do expect button to match the snapshot. And now let's run our tests in watch mode so we can actually implement the button as we go. So it looks like it's erroring. The test failed. Why did it fail? Well, obviously, it can't find my button because we haven't even written the file yet. So let's go ahead and do that. So let's open up MyButton.js. And let's just do export default empty. Just an empty function. We'll save that. We'll see what the tests have to say. OK. Now a different error. Renders, it failed. React isn't defined. Maybe we should import React from React. So in both these files, let's do import React from React. And now the tests run again. And another one fails. What happened here? Well, it didn't actually render. Invariant violation. We wanted a component, but nothing was returned from render. Well, great. Our tests caught an error. So it's not actually rendering anything. So now let's actually render a button. So let's import button from react-native. And now we can do just return a button. And now we're seeing a test failure. We can go up. And we can see, oh, this failed because we're not using button correctly. We need to pass a title to button. OK, so our test's still failing. Let's go back and fix that. So we can say the title should be, I don't know, test button. And maybe let's also get ahead and pass on press as some empty function because that's also something that's required. And now look it. It passed. Yay. Our first test, which is just saying the button should render, it now passed. The button did in fact render. And so now let's try to add one more feature. Let's it correctly overrides color, the default color. So what we want to happen is we want to be able to pass a button color as a prop to the button and make sure that it overwrites any default. So let's do const color is red. And const button is the render.create that. And rather than just immediately JSONifying it, let's actually grab the root component. Let's grab the button out of it. So let's just do .root. And so if you look at the React test render docs, it says you can get the instance of a class or a component by just doing .root. And you'll get the outer one. Now we want to check the props to make sure that the color matches the color that we specified. And so we can do expect button.props.color.toBe-- and what do we want it to be? Well, the color that we specified. And maybe here we should pass in the color is the color. So now let's run this and expect it to fail, which it didn't. Interesting. Let me make sure I'm using it correctly. I'm surprised it worked because it shouldn't be. Let's see. My button color color. We passed color here. It doesn't get set. So this is supposedly undefined. Let me just console.log the button. Let me console.log the button's props and ensure that the color is what we expect it to be. So it's receiving color red, which is interesting. Oh, because we don't want the root. We actually want the button part of it. So the reason that it's failing is because we're not grabbing the correct part, the correct component here. But since we're running low on time, I will just write it up at home and post it. But let me just move on so that we can get to the rest of the lecture first. And so assuming that this code works, we can see that by writing the tests before we write the code, we can start to write a spec that allows us to then implement whatever component or function we want to implement the spec that we described. And so we can use integration tests like these snapshot tests here to ensure that our app retains all of its features and we don't break any features as we start to add new things. And so how do we then-- how can we tell how much of our app is actually being tested? Because right now, we can kind of remember, oh, we tested Redux. We tested this button. We haven't really touched the app. We haven't touched any screens. So we can kind of keep track in our head. But there's no real way for us right now to know exactly how we do that. And it turns out there's actually a metric. It's called code coverage. And so this is a metric for tracking how well-tested an application is. And there is a few different numbers. One is statements, or in other words, how many statements in this program have been executed? Another is branches. And so just like we were discussing before with a couple of different possible code paths in our reducer or action, this is the metric for that. It's how many of the possible code paths in all of our application have been executed or have been tested? Another one is functions, meaning we have n functions in our app. How many of them are actually tested? And lastly, just straight lines of code. So out of all of the lines of code in our app, how many have actually been executed by our tests? And so how do we get this coverage report? Well, it turns out we just pass another flag to Jest. We can just pass --coverage, and it will let us know all of these numbers. And so we can do npm test -- --coverage to pass that in. And it will run all of our tests and at the very end output a table. And if I zoom out so that it formats correctly, we can see all of the numbers here. So we can see all of the files that we've tested. And so we tested api.js. We tested MyButton.js. We tested actions. We tested reducer. And in here, it tells us exactly how many of each metric have we actually tested. And so in sum.js, if you remember, it was just a single function that returned with the sum of two numbers. It turns out with the tests that we wrote, we hit 100% of the statements, 100% of the branches, 100% of the functions, and every single line, as well. But for the things less tested, like our reducer, just like I mentioned, oh, we're going to not test the sections of the reducer. We see that only 85.71% of the statements are tested, which equates to 78% of the branches. But every single function was tested because if you remember, there was just the user reducer, the context reducer, and the reducer, and maybe a merge function. But we ended up invoking every single function, so 100% of those are tested. But we only ended up touching 83.33% of the lines of code. We can see that lines 19, 20, and 21 were not actually tested. And then for the things that we really didn't test at all, we see some very low numbers. And so that is the code coverage report for the tests that we just wrote. And so we've talked about unit tests and we've talked about integration tests. But what about end-to-end tests? And so unfortunately, currently there's no real easy way to run automated end-to-end tests in React Native. But there's a great work in progress by Wix. And so the company Wix is writing this library called Detox which actually handles end-to-end testing in React Native. If you want to check it out, the github link is here. There's also a github link where Brent started putting together integration between Expo and Wix Detox tests. But right now, it really lacks Android support. And so it's not quite ready to test your apps. But I encourage you to follow along with this project just because it allows you to do really cool things like this. And so you see here it's automatically downloading a bunch of stuff. It's going to run the app. And it's going to just zip through the app testing all of your features, as if it were a user running the application. And so this is truly end-to-end because it basically simulates an end user lifting up the app and testing all of the functionality very deeply. And so everything, all the code that executes in order to do a certain feature is executed by this Detox application. And lastly, I just wanted to give some thanks to everybody who's been helping out with this course, to the CS50 team, including David, the production team, and everybody else who just made this possible. And then good luck on your final projects. I'm really excited to see what you all build. And thank you for joining us for this semester.
B1 中級 部署、測試--第12講--CS50的移動應用開發與React Native。 (Deploying, Testing - Lecture 12 - CS50's Mobile App Development with React Native) 2 0 林宜悉 發佈於 2021 年 01 月 14 日 更多分享 分享 收藏 回報 影片單字