Testing and CI/CD - Lecture 7 - CS50's Web Programming with Python and JavaScript 2020

Video Statistics and Information

Video
Captions Word Cloud
Reddit Comments
Captions
[MUSIC PLAYING] BRIAN YU: OK. Welcome back, everyone, to web programming with Python and JavaScript. And now, at this point, we've seen a number of different techniques and tools that we can use in order to design web applications-- HTML and CSS, to describe how it is that our pages look; a programming language, like Python, using a framework like Django, in order to listen for requests, process them and provide some sort of response. And then more recently, we took a look at JavaScript, another programming language that we can use in particular on the client side, running inside of the user's web browser, in order to make our web pages even more interactive and user-friendly. Now what we'll transition to today is taking a look at some of software's best practices, some tools and techniques that developers actually use when they're working on web applications, especially as those web applications start to grow larger. In particular, we'll start by discussing testing, this idea of verifying that our code is correct, and then transition to CI/CD, short for Continuous Integration and Continuous Delivery, some other best practices that are used in making sure that the work that software developers are working on can be tested and deployed readily and very quickly. So we'll begin the conversation with testing. And testing is really about this idea of verifying and making sure that the code that software developers are writing are, in fact, correct, to make sure that the functions work the way they're supposed to, that the web pages behave the way that they're supposed to. And ideally, we'd like some way to be able to efficiently and effectively test our code over time, and as our programs grow more complicated, to allow our tests is to make sure that our program is behaving the way that we want it to. So we'll go ahead and start simple and consider the basic way that we might take a function, for example, written in Python and test and verify to make sure that it works the way we would expect it to. And to do so, we can start with a command in Python known as assert. And what assert does in Python is it asserts or just states that something should be true. And if that something is not true, then the assert is going to throw an exception, some sort of error, so that whoever is running the program or running the command knows that something went wrong. And this can be a very basic way that we can leverage Python's abilities to test a function and verify that that function behaves the way we would want it to. So let's go ahead and try a simple example of writing a Python function and then trying to test to make sure that that function works the way we would want it to. So I'll go ahead and create a new file. I'll call it assert.py. And let me define a new Python function, for example, that is going to take an integer and square it. Just want to take a number and return it square. So I'm going to define a function called square that takes as an input a number, like x. And I want to return x times x. It's a fairly straightforward function. But I would like to now verify that the function works the way I would expect it to. Now there are a number of ways that you could do this. One would be just like let's print out what the square of 10 is, for example, and just see what that's equal to. And then you could run a program, something like python assert.py and just say, all right, the answer is 100. And I could say to myself, OK, that's what I would expect it to be. But I now have to do the mental math of squaring the number 10, making sure that the answer comes out to be the value that I expect. It would be nice if I could automate this process. Well, one thing I could do is print out, does the square of 10 equal 100? I know that I want the square of 10 to equal 100, so I could just print out that value, print out, does square of 10 equal the number 100? I go ahead and run the program again. And this time, what I get is, true, for example, because those two things are equal to each other. And if, on the other hand, I had tried to check for something that wasn't true, like does square of 10 equal 101? You run the program, and, OK. Now it's going to be false. So this is nothing new, nothing we haven't seen before. But now what I can do is, instead of this, I can just say, let me assert that the square of 10 is equal to 100. Here I am just asserting that this expression, that the square root of 10 is equal to 100 is going to be true. And now I can run the program. And what you'll notice is nothing happens. No output, nothing at all because when an assert statement runs and the expression that it's checking turns out to be true-- that the square of 10 does equal 100-- it effectively ignores that statement altogether, just continues on to the next thing, no output, no side effect of any sort. And this is helpful because it just means that if I want to assert that something is true, I can assert it and then just continue writing my code. And it's as if I hadn't written that assert statement at all so long as the thing that I am asserting is actually true. But if there were a bug in my code, for example, some sort of mistake, where instead of returning x times x, imagine that I accidentally said, return x plus x to calculate the square instead, something that would be a bug in this case. Well, then when I try to run python assert.py, what I'm going to get is an exception. And the type of exception that I get is something known as an assertion error. And I can see that here. There is an assertion error. And then I see the reason why the assertion error happened. And the assertion error happened on line 4, which is this line here, where I said, I would like to assert that the square of 10 is equal to 100. So one way we can imagine testing our code is just by including a number of these different assert statements. If I want to verify that my code is correct, I can write various different assert statements. And for a function that's fairly simple, like the square function, probably not too many tests that I would need to write. But you can imagine for more complex functions that have multiple different conditional branches, being able to assert that no matter which conditional branch the program chooses to follow that the code will actually be correct can be a valuable thing to be able to say. And this can be helpful too when in working on a larger project, you want to deal with the problem of bugs that might appear inside of a project. And this gets at the idea of test-driven development, developing while keeping this notion of testing in mind. And one of the best practices would be if ever you're working on a program of your own and you encounter some bug in the program, you'll first want to fix the bug. But then you'll want to write a test that verifies that the new behavior is working as expected. And once you've written these tests, these tests can start to grow over time. And as you continue working on your project, you can always run those existing set of tests to make sure that nothing-- no new changes that you make to the program down the line, no future features that you add or changes you might make-- are going to break anything that was there before. And this is especially valuable, as programs start to get more complex and testing everything by hand would start to become a very tedious process, to be able to just automate the process of just run a whole bunch of tests on all of the things that I know that I would like the program to do and making sure they work as expected. That can be quite helpful. So assert then is one basic way of just saying that I would like for this statement to be true. And if it's not true, go ahead and throw an exception. And using Python, we know we also have the ability to catch those exceptions in order to make sure that we're able to handle those appropriately. So we can display a nice error message for example, if we wanted to do so. But now let's go ahead and try and write a more complex function, something more complex than just taking a number and squaring it, somewhere where there's more room for various different cases that I might want to test and more room where I, the programmer, might make a mistake, for example. So let's imagine writing a new file-- and then I'm going to call prime.py-- where here, I'll go ahead and say that I would like prime.py to implement a function called is_prime. And what the is_prime function should do is check to see if a number is prime or not. The prime number only has factors of 1 and itself. And I would like to write a function that verifies that fact. And so how might I go about doing that? Well, if n is less than 2, then it is definitely not prime because we say 0 and 1 are not going to be prime. And we'll only deal with numbers that are 0 or greater. And we'll deal with that for now. But let's start then with other numbers, numbers that are 2 or greater. Well, what do I want to do? I really want to check each of the possible factors. Like if I want to check whether or not 100 is prime or not, then I want to loop over all of the possible numbers that could be factors of 100, like 2, 3, 4, 5, 6. And when I get to a number like 2 or a number like 5 that do go into 100 cleanly, well, then I'll know that the number is not prime. So I could say for i in range from 2 all the way up through n, for example, let me go ahead and say, if n mod i equals 0, then return false. So what am I saying here? I'm saying go ahead and start at 2. Go up through but not including n. So if I'm checking to see if 10 is prime, for example, I'm going to check for i is 2, 3, 4, 5, 6, 7, 8, 9. And for each of those numbers, check if n, my input to this function, mod i, the factor that I would like to check, is equal to 0. This mod operator, this percent, if you don't recall, gives us the remainder when you divide one number by another. And so if n mod i equals 0, that means the remainder when you divide n by i equals 0-- meaning i goes into n cleanly with no remainder. And that means that it's not prime because it does have a factor. Whatever i is is going to be that factor. And if I get to the end of this for loop, then I can go ahead and just say, return true. If we weren't able to find a factor for the number other than 1 and the number itself, well, then, we can go ahead and say that true, this number is going to be prime. And so this, for example, could be a function that checks to see if a number is prime. But if I'm trying to optimize, I'm trying to make my function more efficient, I might realize that you really don't need to check every number from 2 all the way up to the number and itself. I could really just check up to the square root of that number, for example. That for a number like 25, I want to check 2, 3, 4, 5 because 5 squared is going to be 25. But after 5, I don't need to check any more numbers beyond that. That after you get to a number, after a number-- the square root of that number is multiplied by itself, there's never going to be a case where a number bigger than that could be a factor that I won't have already known about. So just thinking about things a little bit mathematically, we might be able to make some sort of optimizations where instead of going from 2 all the way up through n, I might go up to the square root of n. And I'll go ahead and import math to be able to use math dot square root. And I'll convert that number to an integer just in case the square root doesn't already happen to be an integer. So I think this works. I've at least talked myself into thinking that this is a function that might be able to check if a number is prime. So what could I do if I wanted to verify this? Well, I could write some assert statements. Another thing I could do is just use the Python interpreter. I could say, all right. Let me go ahead and type python. And I'm in the Python interpreter. And I can say, from prime, go ahead and import is_prime. prime is the name of that file. is_prime is the function in that file that I would like to test. And let's just try, all right, is_prime(5)? That's a prime number. Hopefully, it'll say true, that it's prime. All right, it does. Let's try is_prime(10)? See if that works. All right. is_prime(10) is false because 10 is not prime. That's good. That seems to be working as well. Let's try is_prime(99)? That's not prime because 3 is a multiple of that, for example. All right, false, so that's good. This seems to be working. And I could, in the interpreter, test this function to make sure that it works the way that I would want it to work. But let's now see some other ways that I might go about testing it. Well, one way is that I could write a file like tests0.py. And what tests0.py is going to do-- instead of using assert, I'm just going to do our Boolean checks like we were doing before. I'm going to import the is_prime function. And I've defined a new function called test_prime, which is going to serve the role of testing to make sure that when you square some number or when you check to see if some number n is prime, that you get some expected value, where that expected value is either true for it is prime or false for it's not prime. What, then, is this function doing? Well, the function is checking. We're calling the is_prime function on this number n and seeing whether or not it is equal to the expected value that we get, where we expect it to be either true or false. And if we run is_prime on n and it is not equal to what we expect, well, then, we print out, OK, there is an error. We expected some value true or false. But it turned out not to be the case. And so now that I have this test_prime function, well, I can say, all right. Let me go back into the Python interpreter. From tests0 import test_prime. And now I can say, all right, let me test_prime. Make sure that 5 is prime. So I'm passing in with my first input the number n, the number I would like to check. I want to check if 5 is prime. And the second input I provide is what I expect it to be-- either true or false. And here, nothing happens, which is a good thing. If there were an error, it would have printed something out. And the fact that I see nothing printed out means that everything was OK. If I test_prime now and say something like, all right, make sure 10 is not prime-- make sure that 10 when you pass it into is_prime is going to give us false. Again, nothing happens. Seems to be working just fine. Let me now try-- I can try more examples. Maybe I try test_prime 25. I want to make sure that 25 is not prime because 25 is not a prime number. All right. We get some sort of error. There's an error on is_prime(25) where I expected the output to be false. But for some reason, it looks like is_prime returns something other than false. It probably returned true. And so might indicate some sort of bug in my program, that somehow I don't think that 25 should be a prime number. But my program thinks that 25 is a prime number. And that error can be a clue to me as to how to do this. But ultimately, especially as programs start to grow longer, especially as I start to add more and more functions, testing each of those functions by hand is going to start to get tedious. So one thing I could do is write a script to be able to run all these tests for me automatically. And so here what I have a tests0.sh, .sh being like a shell script, some script that I can just run inside my terminal. And what this is doing is it's running Python 3, for Python version 3, dash c, which means I'm just going to give it a command. And it is going to run that command. And so I can just run these. And each of these lines does what? From tests0, it imports my test_prime function, that function that is going to test to make sure that the prime function produces the output that I would expect it to. And each time I'm testing a different number, making sure that 1 is not prime, making sure that 2 is prime, 8 is not prime, so on and so forth. And I can just write a whole bunch of these tests. And then rather than have to run each test one at a time, what I can do is I can just run tests0.sh. I can just say that I would like to run ./tests0.sh. And, all right. I see that I get two errors. I get an error on is_prime(8), where I expected it to not be prime. But for some reason, it seems to be prime. And then again, here, exception on is_prime(25), where I expected it to not be prime. But for some reason, my program thinks that it is prime. So a very helpful way for me to know immediately that there's some sort of error that is going on here. But ultimately, rather than have me have to write all this framework for how to go about testing my code on my own, there exists libraries that can help us with this. And one of the most popular in Python is a library known as unittest. And what unittest as a library designed to do is it is going to allow us to very quickly write tests that are able to check whether something is equal to something else. And then unittest is built in with an automated test runner that will run all of the tests for me and verify the output. And unittest gets built in to a lot of other libraries within Python. We'll see how we'll soon be able to apply this sort of idea to our Django applications as well. But let's now translate these tests that we have written ourselves just by writing a function like test whether the prime number is what we expect it to be and now translate it to using this Python unittest library instead. And so just to get a sense for what this looks like, I'll now go ahead and open up tests1.py, where here first thing I'm doing is I'm importing unittest, which we get for free with Python. I'm also importing the function that I would like to test. And now I'm defining a class which will contain all of my tests. This is a class that inherits from or derives from unittest.TestCase, which means that this is going to be a class that is going to define a whole bunch of functions, each of which is something that I would like to test. And so, for example, in this very first test, this is a test that checks to make sure that 1 is not prime. And so the way I do that is by calling self-- this testing object itself. It happens to have a method or function built into it called .assertFalse. There's an equivalent .assertTrue. But I would like to .assertFalse. And what would I like to assert that is false? is_prime(1). So whatever is_prime(1) is, that should be false. And I would like to just assert that it is false. Likewise, for the number 2 now, I want to check that the number 2 is prime. And the way I do that is by calling self.assertTrue. I would like to assert that when I run the is_prime function on the number 2, the output that I get is going to be a true value-- self.assertTrue. And I can translate each of the rest of my tests into one of these self.assertTrues or self.assertFalse. And then I say that if you go ahead and run the program, go ahead and call unittest.main, which will run all of these unit tests. So now, when I run python tests1.py, here's what I get. I get some nice output, where up at the top, I see dots every time a test succeeded and a letter F for a test that happened to fail. It says that it ran six tests. And down in the bottom, I see that there were two failures. So it's immediately going to tell me exactly what failed. And it'll give me some rationale, some reason for why it is that those tests failed as well. So we can see, all right. Here is one test. Here is another test. This test that failed is the test that checked that 25 is not prime. And this sentence here is what I supplied inside of what was known as a Python docstring inside of those triple quotation marks, underneath the declaration of the function. Those triple quotation marks, otherwise known as a docstring, serve a number of purposes. They can serve as just a comment for describing what it is the function does. But they're a special comment insofar as someone who's looking at the function can access that docstring that's usually used for documentation for what it is that the function is doing. And they can use it inside of other places as well. And so what unittest is doing is for every function, it uses that docstring as a description of what the test is testing for, so that if a test fails, then I can see exactly what the name is of the test that failed and what it was tested, where the description describes what was happening. Now in this case, where it's just one function and I'm testing a whole bunch of different numbers, it doesn't seem all that useful. But again, if you imagine projects that start to get more complex, being able to know immediately when you run your tests which parts of the program or which parts of your web application are working the way they are expected to can actually be quite helpful. So test 25, that was the function that triggered an assertion failure in this case. And the line that caused it was self.assertFalse(is_prime(25)). And the reason that it failed is because True, which apparently was the output of this function, is not false. And I expected it to be false instead. And so multiple, different ways of trying to run our tests. This happens to be one quite popular one. But this now tells me that I should go back and try and fix my is_prime function. I can go back into prime.py and say, all right. I would like to figure out why this went wrong. And if you look at this enough and maybe give it a little bit of testing, you might see that I have a slight off by 1 error, that I probably need to check one additional number than I actually am because in checking whether or not 25 is prime or not, for example, I might need to go up to and including the number 5 to know that 5 is a factor of 25. But before, I was going up to the number 5, but I wasn't including the number 5. So I also need to just check one more number. And now to verify that this is right, I could just manually test the function myself. Or I could just run these tests again, run python tests1.py. And this time, all these dots mean all these tests succeeded. We ran six tests, and everything was OK. No failures. And so this can be a helpful way for me to know immediately that things seem to be working OK. So the takeaways from here are that these tests can definitely help as you begin to write new changes to your program, especially as you begin to optimize functions. You might make a function more efficient but then run your tests to make sure that in making these improvements, you haven't broken anything. You haven't changed any behavior, that the way the program was supposed to behave and now it doesn't behave that way, you are able to verify with much more confidence that that is true. But of course, that only works if your tests have good coverage of all the things that you would want the function to do, and you've covered appropriately all the various different cases for how the function should behave because only if the tests are comprehensive, will they actually be useful to you in indicating that the change that you made isn't going to break anything. And only then can you actually feel confident in those changes themselves. So now let's take this idea of using unittest to be able to write these tests that verify that a function works and apply it to something like a web application-- like a web application written in Django that we would like to now use in order to be able to test to make sure that various, different functions inside of our Django web application work as well. So what I'm going to do is actually take a look at the airline program that we wrote back when we were first talking about Django and first talking about storing data inside of databases. And I'm going to open up models.py where you see that I've made one addition to our definition of a flight. And recall from before, when we first introduced this idea of defining a model inside of our application for a flight inside of an airline, we gave that model three properties. It had an origin and a destination, where both origin and destination referenced an airport object, where an airport object was an object we defined separately. But a flight has an origin airport and a destination airport. And in addition to that, every flight has a duration, some number of minutes long that that flight is going to last. And I might like to have some way to validate, to verify that a flight is a valid flight, that there isn't some error somewhere in how the data was entered into the database. I would like to just generally make sure that given a flight, I can check to make sure it's a valid flight. And what does it mean for a flight to be valid? Well, in general, given these particular fields, I'll say there are two things that need to be true for a flight to be valid. The origin and the destination need to be different. That's condition number one. It wouldn't make sense to have a flight whose origin and destination are the same airport. And condition number two, the duration of the flight needs to be greater than 0 minutes. If ever the duration is 0 or the duration is negative, that probably indicates to me that there was some sort of mistake in data entry or some problem that happened with how it is that these flights were configured. So I want to make sure that the duration is greater than 0. And those then are my two conditions for what makes a valid flight. And I've, in fact, written a function here called is_valid_flight that just works on this flight class that simply checks given a flight, make sure that it is, in fact, valid. And the way it's doing that is by checking for these two conditions that I've just described. It's checking to make sure that the origin is not equal to the destination. It's checking to make sure that the duration of the flight is greater than or equal to 0. And maybe I should change that to greater than to make sure it's entirely positive. But this then is my definition for what it means for something to be a valid flight. And what I'd like to do now is test these various, different parts of my application. I have this is_valid_flight function inside a flight that I might like to test as well. But we also have all of these other properties that I would like to test, these relationships, that a flight has an origin and a destination. We have passengers that can be associated with flights. So there's lots of relationships between my data that I would like to test and verify to make sure they work the way we would expect it to. So to do that, whenever we create an application in Django, like this flight's application here, we were also given this tests.py file. And we haven't yet used the tests.py file for anything. But what it's supposed to be used for is for writing these sorts of tests, testing that verifies that our application behaves the way that we want it to behave. So let's go ahead now and open up tests.py and see what happens to be in here. What we can do is we can define a subclass of TestCase, which behaves very similar to unittest and is based on that same idea. I'll define a new class called FlightTestCase that will just define all of the tests that I would like to run on my flight's application. And so things to know about this is that first, I might need to do some initial setup in order to make sure that there's some data that I can actually work with and test with. And what Django will do when I go ahead and run these unit tests is that it will create an entirely separate database for me just for testing purposes, that we have one database that contains all the information that actually pertains to the flights that are actually there on my web server. But we might also like to just test things with some dummy flights and some dummy airports just to make sure that things are working. And then once we're confident things are working, then we can deploy our web application to let actual users begin to use whatever new features we've added to the web application, for example. So inside of this database, I might need to do some initial setup. And I can do so by defining a setup function inside of my test case class. This is a special function. And Django knows that when it's running these tests, it should first do any of the setup steps that we need to do. And so how are we doing this? Well, what we're doing is inside of the setup, we're going to just add some sample data into the test database. Again, this won't touch the database that users actually see and actually interact with. This is just our test one for testing purposes. And we'll start by going ahead and creating some airports, so Airport.objects.create, and then specifying what the values for these fields should be. We'll just have an airport whose code is AAA for city A and an airport whose code is BBB for city B. Just a dummy airport names. They're not real airports but just used for testing purposes. And I'll save those airport objects inside of these values, a1 and a2. And beneath that, what I'm going to do next is go ahead and create some flights where I create using Flight.objects.create three different flights, one that goes from a1 to a2 with the duration of 100 minutes; one from a1 to a1 with a duration of 200 minutes; one from a1 to a2 with a duration of negative 100 minutes. So I have a whole bunch of these flights now that I would like to test, that I would like to make sure work in some predetermined or expected way. And so now if I scroll down, we can see that I have a whole bunch of these various different tests. Here is one test that just tests the departures count. So every airport has access to a field called departures, which ideally should be how many flights are departing from that airport. And I'd like to make sure that departures_count works the way I expect it to. So here, I go ahead and get the airport whose code is AAA. And now, using unittest-like syntax, I would like to say self.assertEqual. So assertTrue verifies if something is true. assertFalse verifies if something is false. assertEqual verifies that two numbers are equal to each other. And here, I'd like to verify that a.departures.count-- if I take airport a and count how many flights are departing from that airport, that that should be 3, so just verifying that works. And then after that, if this test passes, then I can be confident that elsewhere in my program, if I take an airport and call that airport.departures.count, I can feel pretty confident that that is going to work the way I would expect it to. I can do the same thing for arrivals, get the airport, and assert that a.arrivals.count, that that is going to be equal to the number 1 if there's only one flight that arrives at airport a1, for example. So that tests these relationships. And I can also now test the is_valid_flight function as well, that here I get my two airports, a1 and a2. This is the one whose code is AAA. This is the one who's code is BBB. I'll go ahead and get the flight whose origin is a1, whose destination is a2, whose duration is 100. And let me just assertTrue that this flight is going to be a valid flight because this fight is valid. The origin is different from the destination. Its duration is some positive number of minutes. And so I should feel pretty confident that this is going to be a valid flight that I can verify by calling self.assertTrue. I can do the same thing for testing for an invalid flight, testing for an invalid flight because the destination is bad. I can get the flight, airport a1, and get the flight whose origin and destination are both a1. And now let me self.assertFalse, say that this should not be a valid flight because the origin and the destination are the same. What's the other way a flight can be invalid? Well, a flight can be invalid because of its duration. So I could say something like, go ahead and get airports a1 and a2. And get me the flight whose origin is a1, destination is a2, but the duration is negative 100 minutes. That was one of the flights as well. And, well, that should not be a valid flight. So I'll say self.assertFalse is_valid_flight because when I call is_valid_flight on that flight, it shouldn't be valid because the duration makes it an invalid flight. So here now I've defined a whole bunch of tests. And there are more done below that we'll take a look at in a moment as well. But I've defined a whole bunch of these flights now or a bunch of these tests. And now I'd like to run them. And the way that I can run tests in Django is via a manage.py command. manage.py has a whole bunch of different commands that we can run. We've seen makemigrations and migrate and runserver. But one of them as well is if I go into airline0, I can say python manage.py test that's just going to run all of my tests. And we're right. It seems that we ran 10 tests. But two of them failed. So let's go ahead and see, why did those two tests fail? Well, the way to read this is that we get this heading anytime a test failed. And so we failed the test_invalid_flight_destination function. And we failed the test_invalid_flight_duration function. And docstrings could have helped me to know what it is that these tests are exactly doing. But it seems that true is not false. I wanted to assert that this should not be a valid flight, that it should be false. But for some reason, these appear to be valid flights. So something seems wrong with is_valid_flight where it's returning true when it should be returning false. And so this then gives me a place to start looking. I can say, all right. Let me go to is_valid_flight and make sure that function is correct. So I'll go back into models.py. I'll take another look at is_valid_flight. Maybe I'll think through the logic again. All right. I wanted to check that self.origin is not self.destination. I wanted to check that the duration is greater than or equal to 0. I could change this to greater than. But I don't think that's the issue because my duration was negative. And so that already should have been invalid. But the other thing I might realize looking at this now is that, OK, the logical connectives that I have used was not the right one. I want to check that for it to be a valid flight, it needs to satisfy both of the conditions. The origin and the destination need to be different. And the duration of the flight needs to be greater than 0, for example. And here, I've used "or" instead of "and." So I can just change it. All right. And hopefully, that will fix things. And to verify as much, I can rerun python manage.py test. Go ahead, and press Return. It's going to check things. I ran 10 tests. Everything is OK. And all right, it seems that now I have passed all of these tests. I notice here at the top, it created a test database. So it just created a test database for me in order to do all this testing work. And then it destroyed that test database at the end as well. So none of my tests-- I'm adding data, removing data. It's not going to touch any of the actual data. Inside of my database for the web application, Django will take care of the process of keeping all of that separate for me by first calling that setup function to make sure that my new test database has everything that it needs to. So, all right. We now have the ability, by using unittest, to be able to test various, different functions inside of our web application. We first saw that we could test a function like the is_prime function, just a Python function that we wrote. But we can also test things like functions on our model, like checking to make sure that a flight is valid, checking to make sure that we can take a flight and access all of the-- or take an airport, and access all of its arrivals and all of its departures. But I'd like to do more than that, especially for a web application. I'd like to check that particular web pages work the way that I want them to work. And so to do that, Django lets us simulate trying to make requests and get responses to a web application. And so let's go ahead and look at some of these other tests as well. Here what we have is a function called test_index. And what test_index is going to do is it's just going to test my default flights page to make sure that it works correctly. So we start by creating a client, some client that's going to be interacting request and response style. Then I'm going to call client dot get slash flights. That is the route that gets me the index page for all the flights. And I'm saving that inside of a variable called response. Whatever response I get back from trying to get that page, I would like to save inside of this variable called response. And now, I can have multiple assert statements inside of the same test if I would like to. Sometimes you might want to separate them. But here, I want to check that the index page works. And what that means is a couple of things. It means that response.status_code, well, that should be equal to 200. 200, again, meaning, OK. I want to make sure that whatever response I get back, that that is going to be a 200. And if there was some sort of error, like a 404 because the page wasn't found or a 500 because of some internal server error, I would like to know about that. So let me first just assert that the status code should be equal to 200. But then Django also lets me access the context for a response. And what is the context? Well, recall, again, in Django, when we rendered a template, for example, we called return render then provided the request and what page we were going to render. But we could also provide some context, some Python dictionary, describing all of the values that we wanted to pass in to that template. And Django's testing framework gives us access to that context so that we can test to make sure that it contains what we would expect it to contain. And on the index page for all my flights, I would expect that to contain a listing of all of the flights. And we created three sample flights inside of this test database. So I should be able to assert that these two things are equal. response.context flights, that gets me whatever was passed in as flights in the context. Dot count, well, that better be 3 because I want to make sure that there are exactly 3 results that come back when I look at the context and access whatever it happens to be inside of that flight's key. There are other tests I can run as well. So in this case, I've gone ahead and gotten a particular flight, the flight who, in this case, had an origin of a1 and a destination of a1, that it was not a valid flight. But we'll go ahead and get it anyway because it exists in the database. And now I can get slash flights slash that flight's ID because on my flight's page, I would like to be able to go to slash flights slash 1 to get at flight number 1, and go to slash flights slash 2 get at flight number 2. So if I take some valid ID, some ID of an actual flight f and go to slash flights slash dot id, well, that should work. It should have a status code of 200. Meanwhile, though, if I test an invalid flight page, this is a Django command that will get me the maximum value for the ID. This id__max gets me the biggest possible ID out of all of the flights that happen to exist inside of my database. If I go ahead and try and get slash flights slash max_id plus 1-- so a number that is 1 greater than any of the flights that were already inside of my database-- well, that shouldn't work. There shouldn't be a page for a flight that doesn't exist. So here then, I can assertEqual that the status code of what comes back, that that is equal to 404 because I would expect that page to return a 404. And finally, I can also check various different contexts for things about the passenger page. So in this case, I've added some sample passengers to the database. So inside of my test, I can manipulate the database as well, adding data into the database and checking to make sure that when you count up the number of passengers on a flight page, that that is going to be like the number 1, for example. So a number of different tests that we can then write in order to verify various different parts of our web application. I would like to verify not only that our database works the way we would expect the database to work in terms of how functions on our models work, in terms of relationships between those models like the relationship between a flight and an airport, but we can also simulate a GET request, simulating the request to a page and verify that the status code that comes back is what we would expect it to be; verify that the contents of the page contain the right contents; verify that the context that was passed into that template is correct as well. And then all of that can then be verified by saying something like python manage.py test then go ahead and running that. And we see that in this case, all of the tests happened to pass, which means that everything seems to be OK, at least for now. So this then, again, very helpful as our web programs start to get more complex, as we have multiple different models, multiple different routes, the ability to test, to make sure that if we change things in one part of the program, that it doesn't break things in another part. That can be quite helpful too. What we haven't yet been able to test though is any interaction that's happening exclusively in the browser. But I've been able to test a lot of things that are happening server-side. And recall that Django is all about working on writing this web server-- a Python application that acts as a web server that listens for requests that come in from users, processes those using these various different views and models, and then provides some sort of response back. And we can test the contents of that response, things like, does the status code match what we would expect it to? Does the context match what we would expect it to? But there are some times where we would like to really simulate like a user clicking on buttons and trying things on a page and making sure that page behaves we would like it-- how we would like it to as well even if we're not using Django, or even if we're just dealing with the front end. So let's create a sample JavaScript web page that we might like to test now by using these sorts of ideas-- the ability to automate the process of testing various, different parts of our application to verify that they do, in fact, work correctly. What I'm going to do now is we'll get out of the airline directory. And I'm going to create a new file that I'll call counter.html. And recall before, we created a counter application using just JavaScript where what the counter application did is it let me click a button, like an increase or a count button that just incremented a number. It went from 0 to 1 to 2 to 3 to 4 and so forth. I'm going to do the same thing here. We'll add a little more complexity though and give myself both an increase button to add a number and a decrease button to decrease the number by 1 as well. So I go ahead and start with our usual DOCTYPE html and our html tag. I'll give this page a title of Counter. And now, inside of the body of this page, I will go ahead and start with a big heading that just says 0. And then beneath that, I'll create two buttons. I'll have one button which will be the plus symbol and one button which will be the minus symbol. So now no JavaScript yet. This won't actually work. But I can go ahead and go into open counter.html. And I can see that I now have 0, and I have a plus and a minus button, although those Plus and Minus buttons don't actually do anything right now. So let's go ahead and make them do something. Let's give this button an ID called increase so that I can reference it later, and give this button an ID called decrease, again, so that I can reference it inside of my JavaScript. And now, in the head section of my web page, I add a script tag where I want to start running some JavaScript once the page is done loading. And to do that, you'll recall, I can say document.addEventListener c to say, go ahead and run this function once the DOM is loaded, once all the contents of this page have loaded the way I would expect it to. And what am I going to do? Well, I first need a variable, something like let counter equal 0. And then I can say, all right document.querySelector increase. Get me the element whose ID is increase. That's that plus button. And when you are clicked, let's go ahead and add an event handler in the form of this callback function, this function that will be called when the increase button is clicked. And what would I like to do? Well, I'd like to increase the counter. Go ahead and say counter++. And then I'm going to update this h1 that currently contains 0. document.querySelector h1. That gets me the H1 element. I'll go ahead and update its inner.HTML, and go ahead and set that to whatever the value of counter happens to be. And then I'll do the same thing for the decrease button. document.querySelector, get me the element whose ID is decrease-- that's the button that will be the minus button. And when you are clicked, go ahead and run this callback function, which will do the same thing, except we'll first do counter-- decrease the value of the counter by 1, and then get me the H1 element, set its innerHTML equal to the counter. I think this should work. And I could verify that by opening up counter.html. I'll refresh the page. And I can test these buttons. Test the plus button. All right. That seems to work, increases the value by 1. I can test the minus button, make sure that that decreases the value by 1. That all seems to work just fine. But, of course, this requires me having to interact with this page. I have to open up the page. I have to click on these buttons. And it's not the type of thing where I'm simulating like a GET request or a POST request. There is no server that I'm sending a request to and getting back a response of 1, 2, 3, 4 from. This is all happening in the browser. And so what I might like to have the ability to do is some sort of browser testing. And there are a number of different frameworks for doing this. One of the most popular is Selenium. And what this is going to allow me to do is I can define a test file that using unittest or a similar library can effectively simulate a web browser-- can simulate a web browser and simulate a user interacting with that web browser using something that we call a WebDriver that's going to allow me to, using code, control what it is that the browser is doing and how it is that the user is interacting with this program. So how is this going to work? Well, I'm going to go ahead, and just as a test, let me open up the Python interpreter. And let me import from tests import star. And that's going to give me access to a couple of different things. But the first thing you'll notice that it's doing is because I'm using this WebDriver, it's going to give me a web browser. And here I'm using Chrome. But you could use another browser. But notice it here, Chrome is telling me Chrome is being controlled by automated test software, that Chrome has the ability to allow me, using automated test software, using Python code, control what it is the web browser is doing. And so what I can do here is the first thing I need to do is tell Chrome to open up my web page. And it turns out that in order to do so, I need to get that page's URI or Uniform Resource Identifier, just some string that will identify that page. And I've defined a function called file_uri that gets me the URI of a particular file in this directory. So I'm going to say I want open up counter.html. And I need to get it's URI. But now what I can say is driver.get(uri), meaning tell this web driver, this Python-- part of the Python program that is controlling the web browser that I would like to get this web page as if the user had gone to the web page and pressed Return after typing in the URL. So I say driver.get(uri). I go ahead and press that. And what you'll notice on the right-hand side here is that Chrome has loaded this page. I am effectively controlling this web browser window using my Python program. I said driver.get(uri), meaning go ahead and open up the counter.html page. And then, inside of this test window, Chrome has opened that up. And inside my Python program now, using this web driver, I have the ability to see the same things that the user sees when they open the page. So what does a user see when they open the page? Well, they see, for example, the title of the page. So I could say driver.title and see that, all right, the title of this page is Counter. That was, in fact, the title of the page. But I could verify that inside my Python program that by checking that driver.title, by taking my WebDriver, getting the title of the current page that it's looking at, making sure that that is Counter. And likewise, if I looked at driver.page_source, press Return, what I see there in string format-- so it's a little bit messy-- is the content, the HTML content of this page. And you'll notice things like DomContentLoaded, here are my onclick handlers. Here's my h1 that says 0. It's very messy because it's being represented as a Python string. And these backslash n's refer to new lines where there's a line break. But this is the content. And this is really all the browser gets. The browser takes this information and just knows how to render it in some nice, more graphical representation that's easier for a user to look at and, therefore, be able to understand. But this is fundamentally all that my web browser is actually getting back when it tries to load a web page. So what can I do from here? Well, I would like to simulate a user's behavior on this page that assures I can get a page and see the title. But I want to simulate like clicking on the plus button, for example. So in order to do that, first thing I need to do is actually get the Plus button. And to do that, I could say something like driver.find_element_by_id. There are a number of ways that I could try and find an HTML element. But I would like to find an HTML element by its ID. And I know that the Plus button-- the Increase button-- has an ID of increase, for example. And so if I find element by ID, let me find the element whose ID is increase. And, all right. It seems here, that I've gotten some web element object back from my web driver. And I'll go ahead and save that inside of a variable called increase. So what I now have is a variable called increase that represents the Increase button that my WebDriver has found on the web page. It's effectively the same thing as you, the human, going through the page looking for the Increase button. The WebDriver is doing the same thing, except instead of looking for the button based on what it looks like, it looks for the button based on its ID. And so this, again, another reason why it's helpful to give your HTML elements IDs. In case you ever need to be able to find that element, it's very useful to be able to reference that element by its name. But now that I have a button, I can simulate user interaction with that button. I can say something like increase.click to say I would like to take the Increase button and simulate a user clicking on that button in order to see whatever it is that the user would get back when they click on that button. So increase.click, I press Return. And what you'll notice happens is that the number increases-- increases from 0 to 1. It's as if I, the user, had actually clicked on the Plus button, except all I did was say increase.click to say, go ahead and press the Increase button and let the browser do what it would normally do in response. And what it would do in response is get that JavaScript event handler, that onclick handler, and run that callback function that increases the value of counter and updates the h1. So I can say increase.click to simulate increasing the value of that variable. But this is just a function call, which means I can include it in any other Python constructs that I want, that if I want to repeat something like 25 times, for example, and press the button 25 times, I can say for i in range 25, go ahead and click the Increase button. And now, very quickly, 25 times, it's going to click the Increase button. And I'm going to see the result of all of that interaction. So I can simulate user interaction just by using the Python interpreter. Likewise, if instead of increasing, I wanted to decrease, well, then, I'm going to do the same thing. I'm going to say decrease equals driver.find_element_by_id. Let me get the decrease element, the element whose ID is decrease. And now say, decrease.click. And that will simulate me pressing the Decrease button. Press it again. And the more I press it, every time, it's just going to decrease by 1. And if I want to decrease it all the way back to 0, well, then, I'll just do it 20 times. For i in range 20, go ahead and decrease.click. And that's going to go ahead and reduce the count all the way back to 0 by simulating the user pressing a button 20 times. And what you'll notice is that it happened remarkably fast. Like I can simulate 100 presses of the Increase button by saying for i in range 100, increase.click. And very quickly, you'll see that number 100 times go ahead and go up to 100 faster than a human could ever have clicked that Plus button over and over again. And so these tests cannot only be automated, but they can be much faster than any human could ever be in order to test this behavior. So how then can we incorporate this idea into actual tests that we write, into like a unit testing framework that allows me to define all of these various different functions that test different parts of my web application's behavior? Well, to do that, let's go ahead and take another look at tests.py. Inside of tests.py, here, again, is that file_uri function, where that function has the sole purpose of taking a file and getting its URI, and we need the URI to be able to open it up. Then we go ahead and get the Chrome WebDriver, which is going to be what's going to allow us to run and simulate interaction with Chrome. And in order to get Chrome's WebDriver, you do have to get ChromeDriver separately. It is separate from Google Chrome itself. But Google does make it available. And other web browsers make equivalent web drivers available as well if you'd like to test how things would work in other browsers because different browsers might behave differently. And it might be useful to be able to test to make sure that not only does everything work in Google Chrome, but it's also going to work in other browsers that you might expect users to be working with as well. Here, then, I've defined a class that, again, inherits from unittest.TestCase that is going to define all of the tests that I would like to run on this web page, that here I have a function called test_title that's going to go ahead and first get counter.html. It's going to open up that page. And then just assertEqual, let's make sure the title of the page is actually Counter. That's what I would expect it to be. So I can write a test in order to test for that case as well. Here, I test the Increase button by finding the element whose ID is increase and clicking on that button to simulate a user pressing the Plus button in order to increase the value of the counter. And then, what do I want to check? Well, I want to check that when you find element by tag name, h1, and so find_element_by_tag_name, similar to find_element_by_id, except instead of finding something by its ID, it's going to look at what's the tag. And there is only one element that is in h1. And so here I'm saying, go ahead and get me the H1 element and access its text property, meaning whatever it is that is contained inside of those two H1 tags, I would expect that to be the number 1. And I would assert that this is equal to 1. And likewise, I can do the same thing for the Decrease button-- finding the element whose ID is decrease, clicking on that button, and then asserting equal, find me the H1 element and make sure that the contents of it are equal to the number negative 1, for instance. And this final test, just test things multiple times, that three times I'm going to press the Increase button and make sure that after I press the Increase button three times, when I check the h1, check what's inside of its text, that the answer should, in fact, be 3. So now I should be able to go ahead and test this code by running python tests.py. And what that is going to do is it's going to open up a web browser. And what you're going to see, very quickly flashed across my screen, were all of those tests. We tested the increase by 1. We tested decreased by 1. And then we tested like increase 3 times after we had checked to make sure the title was correct. And then we can see here is the output. We ran four tests in this amount of time, and everything turned out to be OK. None of the tests failed. But if one of the tests had failed, well, then, we would see a different output. So let's imagine, for example, that I had had a bug in my decrease function, for example, where the decrease function wasn't actually working. What would that bug look like? Maybe I forgot to say counter minus minus. Or maybe, perhaps more likely, what might have happened is I wanted to-- I already had written the increase function, and I decided to very quickly add the decrease function, and I thought I just-- like Copy/Paste, like copy the increase event handler. The decrease event handler is basically the same thing except I need to query for decrease instead. And maybe I just did that and forgot to change plus plus to minus minus, a bug that might happen if you're not too careful about how you copy and paste code from one place to another. Now when I run these tests, python tests.py, we'll see the simulation. A whole bunch get simulated. And when I go back and check the output of my tests, see what actually happened, I see that we do seem to have an assertion error here. The assertion fail was on the test decrease function. And it happened when I tried to assert that what was inside of the H1 element was negative 1 because 1 is not equal to negative 1. So this is the value of this assertion error as well. And this is helpful, an advantage over just assert. Assert just tells you there is an assertion error. But here, in unittest, we actually get to see if I asserted that two things are equal, it tells me what both of those things are. It tells me the actual output of h1's text. It was 1. But what I expected it to be was negative 1. So it tells me exactly what the discrepancy is. I know that for some reason, it was 1 instead of negative 1. And that can be a clue to me, a hint to me, as to how I can go about trying to solve this problem. And I can solve the problem by going into my decrease event handler, seeing that, all right, this was increasing the counter instead of decreasing it. Change plus plus to minus minus, and now rerun my tests and see all of the test simulated inside of my Chrome Driver. And we ran four tests. And this time, everything was OK. So all of my tests appear to have passed this time. So those, then, are some possibilities for being able to test our code, especially taking advantage of unittest, this library that we can use in Python in order to make various types of assertions about what we would like to be true or false about our code. And unittest contains a number of helpful methods for being able to perform these sorts of assertions. Some of them are here. So we can say things like I would like to assert that two things are equal to each other, which we've seen. There's a counterpart to that, assertNotEqual for making sure the two things are not equal to one another. assertTrue and False, we've seen as well. There are others as well though, things like assertIn or assertNotIn, if I would like to assert, for example, that some element is in some list, for example, or that some element is not in some list. There are other assert methods as well that we can use in order to verify that a part of our program or a part of our web application does, in fact, behave the way we would want to behave. And we can integrate this type of idea into a number of different types of testing. We saw integrating it into Django itself, using Django as unit testing in order to verify that our database works the way we expected it to, and that our views works the way that we expected them to and provided the right context back to the user after the user makes a request to our web application. And there are also applications of unit testing, whether using the framework or not, to browser-based testing, when I want to test inside of the user's web browser. Does it actually work when a user clicks on this button, that the JavaScript behaves the way that I would expect it to. And I don't need to especially use JavaScript in order to do those tests. I didn't write those tests using Python, using unittest, to be able to say, click on the button that has this ID and verify that the result that we get back is what we would expect it to be. So that then was testing. And now we'll go ahead and take a look at CI/CD-- Continuous Integration and Continuous Delivery which refer to two best practices in the software development world that has to do with how it is that code is written, especially by groups or teams of people; how it all works together, and how that code is eventually delivered and deployed to users who are using those applications. So CI, which refers to Continuous Integration, involves frequent merges to a main branch of some repository, like a Git repository, and then automatically running unit tests when that code is pushed. So what does that generally mean? Well, in the past, you might imagine that if multiple people are working on some project at the same time and multiple people are each working on different features or different parts of that project, then after everyone's done working on those features and we're ready to ship some new version of a web application or ship some new version of a software product, well, then, everyone's going to have to take all these various different features and combined them all together at the end and figure out how to then try and deliver that program to users. And this has a tendency to cause problems, especially if people have been working on different big changes all simultaneously. They might not all be compatible with one another. There might be conflicts between the various different changes that have been made. So waiting until everyone is done working on a feature to merge them all back together and then deliver it is not necessarily the best practice, which is why increasingly, many more teams are beginning to adopt a system of continuous integration, that there is one repository somewhere online that's keeping the official version of the code. Everyone works on their own version of the code, maybe on their own branch, for example. But very frequently, all of these changes are merged back together into the same branch to make sure that these incremental changes can be happening such that it's less likely that there's two really divergent paths that the program has gone under, and as a result, it's much more difficult to merge those two paths back together. In addition to frequently merging to its own main branch, another key idea of continuous integration is this idea of automated unit testing, where unit testing, again, refers to this idea of on our program, we run a big series of tests that verify each little part of our program to make sure that the web application behaves the way it is supposed to. And unit tests generally refer to testing particular small components of our program, making sure that each component works as expected. There are also bigger scale tests-- tests like integration tests that make sure that the entire pathway from user request and response, that everything along a certain pipeline works as well. But there are various different types of testing. And the important thing is making sure that anytime some new change is merged into the main branch or someone wants to merge their changes into the main branch, that these tests are run to make sure that nobody ever makes a change to one part of a program that breaks some other part of the program. And in a large enough code base, it's going to be impossible for any one person to know exactly what the effect of one particular change is going to be on every other part of the program. There are going to be unforeseen consequences that the one programmer may or may not know about. And so the advantage of unit testing, assuming they're comprehensive and cover all of these various different components of the program, is that any time someone makes a change and attempts to merge that change into the main branch according to the practice of continuous integration, the fact that it doesn't pass a test, we'll know about that immediately. And as a result, that programmer can go back and try to fix it as opposed to waiting until everything is done, merging everything together, and then running the tests, realizing something doesn't work, and then being unsure of where to begin. We don't know where the bug is, which change happened to cause the bug. If everything is merged more incrementally, it's easier to spot those bugs, assuming there's good coverage of tests to make sure that we're accounting for these various different possibilities. So continuous integration refers to that idea-- frequently and more incrementally updating the main branch and making sure that the tests are, in fact, passing. And it's closely tied to a related idea of continuous delivery, which is about the process of how it is that the software is actually released to users, how the web application actually gets deployed. And there are a couple of models you might go about thinking with regards to how it is that some program or web application gets deployed. You might imagine that the release cycle might be quite long, and the people spend months working on various different features on some software development team. And after they're happy with all the new changes, they've released some new version of the web application. But especially, with web applications that are undergoing constant change, that have lots of users, that are moving very quickly, one thing that's quite popular is this notion of continuous delivery, which refers to having shorter release schedules. Instead of immediately releasing something at the end of some long cycle, you can in shorter cycles make releases every day, every week, or so in order to say that let's just go ahead and incrementally make those changes. Whatever new changes happen to have merged to the main branch, let's go ahead and release those as opposed to waiting much longer in order to perform those releases. And that, again, lends itself to certain benefits, the benefit of being able to just incrementally make changes, such as something goes wrong, you know more immediately what went wrong as opposed to making a lot of changes at once, where if something goes wrong, it's not necessarily clear what went wrong. And it also allows new features to get out to users much more quickly. So especially in a competitive market where many different web applications are competing with one another, being able to take a new feature and release it very quickly can be quite helpful. So continuous delivery is all about that idea of short release cycles. Rather than wait a long time for a new version to be released, release versions incrementally as new features begin to come in. It's closely related to the idea of Continuous Deployment, which CD will sometimes also represent. Continuous deployment is similar in spirit to continuous delivery. But the deployments happen automatically. So rather than a human having to say, all right, we've made a couple of changes. Let's go ahead and deploy those changes. In continuous deployment, any time these changes are made, the pipeline of deploying the application to users will automatically take place as well, just removing one thing for humans to have to think about and allowing for these deployments to happen even more quickly as well. So the question then is, what tools can allow us to make continuous integration and continuous delivery a little bit easier? What techniques can we use in order to do so? And there are a number of different continuous integration tools. But one of them produced by GitHub more recently is known as GitHub Actions. And what GitHub Actions allows us to do is to create these workflows where we can say that anytime, for example, someone pushes to a Git repository, I would like for certain steps to take place, certain steps that might be checking to make sure that the code is styled well. That if a company has some style guide that it expects all of its programmers to adhere to when working on a particular product, you could have a GitHub Action such that anytime someone pushes to a repository, you have an action that automatically checks that code against the style guide to make sure that it is well-styled, well-commented, documented, and so forth. You might also, for instance, have a GitHub action that tests our code to make sure that anytime anyone pushes code to a GitHub repository, we automatically run whatever tests we would like to run on that particular code base. And GitHub Actions can allow us to do that as well by defining some workflow to be able to do so. And so that's what we'll take a look at in just a moment, using GitHub Actions to automate the process of running tests so that the human-- though it would be a good thing for the programmer when they're done writing their code to test their code and make sure it works-- we can enforce that by making sure that every time anyone pushes to a GitHub repository, we'll automatically run some GitHub action that is going to take care of the process of running tests on that program. And we'll know immediately as via an email that GitHub might send to you to say that this particular test failed. And you'll know every time you push to that repository. So how do these workflows get structured? What is the syntax of them? Well they use a particular type of syntax known as YAML, which is some language, a configuration language, that can be used in order to describe-- often described for configuration of various different tools and software. GitHub Actions happens to use it. Other technologies use it as well. And YAML is a file format that structures its data sort of like this, in terms of key value pairs, much in the same way that a JSON object or a Python dictionary might, where we'll have the name of a key followed by a colon followed by its value-- name of a key, followed by a colon, followed by a value. And the value doesn't necessarily need to be just a single value. It could be a sequence of values, like a list of values, for example. And those are generated this way, by like a hyphen indicating a list-- item 1, item 2, item 3. And in addition to just having single values and lists of items, these ideas-- these lists, these sequences, these values-- can be nested within one another, that you might have one key that leads to another set of keys that are associated with values that leads to other sets of keys associated with values as well. Much in the same way, that a JSON object, like a representation of keys and values, can also have nested JSON objects within a JSON object. Likewise, too, we can have nested key value pairs as the value for a particular key too. So we'll take a look at an example of what that actually looks like in the context of creating some GitHub workflow that will run some get GitHub Actions. So what will that look like? Let's go back into airline0, where here, I've defined inside of a .github directory a directory called workflows, inside of which I have a ci.yml file. It can be any name .yml. .yml or .yaml are the conventional file extensions for a YAML file. And here, I'll open up ci.yml. And this now is the configuration for how this workflow ought to behave. I give the workflow a name. It's called Testing because what I want the workflow to do is test my airline application. Then I specify an on key to mean when should this workflow run. And here, I have said on push, meaning anytime someone pushes their code to GitHub, we would like to run this workflow. Every workflow consists of some jobs. And so what are the jobs? What tasks should happen anytime that I try and push to this repository? Well, I've defined a job called test_project. And this is a name that I chose for myself. You can choose any name for a job that you would like. And now I need to specify two things for what happens on a job. One thing I need to specify is what sort of machine is it going to run on? That GitHub has its own virtual machines, otherwise known as VMs, and I would like to run this job on one of those virtual machines. And there are virtual machines for various different operating systems. Here I'm just saying, go ahead, and run on the latest version of Ubuntu, which is a later version of Linux that I would like for this test to run on. And then for the job, I specify what steps should happen where I can now specify what actions should happen when someone tries test a project when I try and run this job. And here I'm using a particular GitHub action. And this is a GitHub action written by GitHub called actions/checkout. And what this is going to do is it's going to check out my code in the Git repository and allow me to run programs that operate on that code. And you can write your own GitHub actions if you would like. But here, all we really need to do is go ahead and check out the code, as by looking at what's on the branch that I just pushed to. And then I'm going to run Django unit tests. This is just a description for me to know what's going on in this particular step. And here is what I would like to run. I'm going to first go ahead and install Django because I'm going to need to install Django to be able to run all of these tests. But after-- and if there are other requirements, I might need to install those requirements as well. But the airline program is fairly simple. All we really need in order to run the tests is just Django. So I'll go ahead and install Django. And then I'll run python3 manage.py test. I would like to test-- run all of the tests. And the way I can do that is just by providing this manage.py command to say that I would like to run all of the tests on this particular application. So this configuration file altogether now is going to specify a particular workflow, the workflow that says that every time I push to the GitHub repository, what I would like to happen is I would like to check out my code inside of the Git repository. So on some Ubuntu VM, GitHub is going to check out my code, and it's going to run these commands. It's going to install Django. And then it's going to test my code. And it will then give back to me what the response is after I do that. So let's go ahead and test this. And in particular, let's run it on a program where the tests are going to fail. So I might say, for example, let's go into flights and models.py. And let's go to my is_valid_flight function from before and change it back to that version that didn't work. That before it was something and something. I'll change it to something or something. That as long as the origin is not the destination or the duration is greater than 0, we'll count that as valid. But we know that that's wrong. That should not work. So here's what I'll do. I'll go ahead and first say git status, see, all right, what's changed? And it seems that, all right, I've changed-- I've modified models.py, which makes sense. I'll go ahead and git add. I'll add dot. We'll just add all of the files that I might have modified. I'll commit my changes. Say go ahead and use wrong valid flight function. That's what I'm going to do. And now I'm going to push my code to GitHub. I added it. I committed it. I pushed it. That now then pushes my code to GitHub into a repository called airline that I already have. And now, if I go ahead and go to GitHub, and I go to my airline repository, what you'll notice is that we've mostly been dealing with this Code tab. But GitHub gives us other tabs as well that are quite useful as you begin to think about working on a project in larger team. So in addition to looking at the code, we have issues. Issues are ways for people to just report that something is not quite right, or there is a feature request that we have for this particular code base. So the issues might maintain a list of all of the pending action items for a particular repository, things that we still need to deal with. And once those issues are dealt with, the issues can be closed. So I have no issues here as well. Pull requests are people that are trying to merge some part of the code from one branch into another branch. So you might imagine on a larger project, you don't want everyone merging things into master all at the same time. You might have people working on their own separate branches. And then when they feel confident and happy with their code, then they can propose a pull request to merge their code into the master branch. And that allows for various other features, like the ability for someone to offer a code review-- to be able to review the code, write comments, and propose suggestions for what changes should be made to a particular part of the code before it gets merged into the master branch. And that's another common practice with regards to working on a GitHub repository or any other larger project that you're controlling using source control is this idea of code reviews, that oftentimes, you don't want just one person making the changes without anyone's eyes on that code. But you want a second pair of eyes to be able to look things over, make sure the code is correct, make sure it's efficient, make sure it's in line with the practices that the application is using. And so pull requests can be quite helpful for that. And then this fourth tab over here represents GitHub Actions. These are the various different actions or workflows that I might want to run on this particular repository. And what we'll see here is that if I go to the Actions tab now, what I'll see is here is my most recent testing actions. So anytime I push, I get a new testing action. This one was from 29 seconds ago. I'll go ahead and click on it and see what's within it. All right. Here was the job that I ran, test_project. I see that on the left-hand side. You'll notice this big red X in the left-hand side of this workflow. Means something went wrong. So I'd like to know what it is that went wrong. I'll go ahead and click on test_project. And here within it, these are all of the steps, the things that happened when we actually ran this particular job. First the job sets up. Then the checkout action goes ahead and checks out my code because we need access to my code to be able to run it. Here was the step I defined-- run Django unit tests, which was going to install Django and run those tests. It has an X next to it, indicating something went wrong. And I see down below, annotations, 1 failure. So all over the place, GitHub's trying to tell me that something went wrong. It failed two minutes ago here. I'll go ahead and open this up. And what I'll see is the first thing that happened is we installed Django. And that seems to have worked OK. But down below, what you'll see is the output of running these unit tests, that we see FAILED (failures-2). And now I can see, here are the unit tests that failed. We failed the invalid flight destination test. We failed the invalid flight duration test. And as before, I can see in GitHub's user interface what those assertion errors are. I can see a true is not false. True is not false, those were the problems that happened when I tried to run this particular test suite. And now others who are also working on this repository can see as well what the results of these tests are and can offer suggestions, can offer ways that I might be able to fix the code in order to deal with that problem. But now I know that this particular test failed. And if I go back to the main code page for this GitHub repository, I'll see that next to this commit, there is a little x symbol. And that little x symbol next to the commit just tells me that the most recent time I tried to commit, something went wrong. They ran the workflow, and there was an error. And so I'll immediately see for this commit-- and I can go back and look at the history of commits and see which ones were OK and which ones had a tendency to cause some sort of problem. So this one, it appears caused a problem. And we know why. It caused a problem because of this condition, something or something else. So I can fix it. I'll change the or to an and. I'll go ahead and git add dot. git commit. Say I will fix valid flight check. If I do git status just to check out what's going on right now, I'm ahead of the master branch by 1 commit. That's exactly what I would expect. And now I'll go ahead and push my code to GitHub by running git push, saying, all right, let's push this update. And now, hopefully, we're going to pass the workflow now. Now I go back to the repository. I refresh the page. Here's my latest commit-- fix valid flight check. You notice here, there's an orange dot instead of the red x as before. This dot just means the tests are currently pending. The workflow is in progress because it takes some time for GitHub to be able to start up the VM, to be able to initialize the job, to check out my code, to run all those tests. It does take some time. But if I go back to the Actions tab, I'll see that, all right. This time, for testing, we get a green check mark. Everything seems to be OK. I go to test_project just to see it. And now I notice the green check mark next to Run Django unit tests means that the unit tests have passed as well. If I open those up, now I see at the bottom the same output that I saw before when I was running those unit tests on my own machine. We ran 10 tests, and everything was OK. And that tells me that these tests have passed. So GitHub Actions have the ability to allow for certain jobs to happen, certain work to happen anytime you push code, anytime you submit a pull request or on various different actions that might happen on a GitHub repository. And they're very helpful for being able to implement this idea of continuous integration because it means you can make sure that when you're merging code from some developer's branch into the main branch that everyone's merging their code into, you can verify that those tests can pass. And you can add rules to say that you don't want to allow anyone to merge code into the branch if the tests don't pass, to guarantee that any code that does get merged is going to pass all of those tests as well. And so that can definitely help the development cycle, make it easier to ensure that changes can be made quickly. But as we make those changes quickly, we're not going to lose accuracy and validity within our code, that we can make sure that our code still passes those tests by automating the process of running those tests altogether. So other than continuous integration then, we now talk about this idea of continuous delivery, these short application cycles where we would like to very quickly be able to deploy our application onto some sort of web server. And when we're deploying applications to a web server, there are things that we need to think about. We need to think about getting our program that was running fine on our computer working on a web server as well. And this can just be fraught with headaches and all sorts of configuration problems because you might imagine that the computer that you are using is not necessarily going to be the same as the computer that on the cloud, the computer in the server where your web application is actually running. It might be running a different operating system. It might have a different version of Python installed. If you have certain packages working on your own computer, those same packages might not be installed on the server. So we run into all sorts of various different configuration problems where you can be developing, deploy your code, and realize that it doesn't work on the server because of some sort of difference between what's happening on your computer and what's happening on the server. And this becomes even more problematic if you're working on a larger team, you and multiple other people working on a software project, but you each have different versions of various different packages or libraries installed, and those different versions have different features and might not all work and cooperate with one another. And so we need some way in order to be able to deploy applications efficiently and effectively to be able to standardize on just one version of the environment, one version of all these packages, to make sure that every developer is working on the project in the same environment. And once we deploy the application, it's going to be working in the same environment as well. And the solution to this comes in a number of possible options. But one option is to take advantage of a tool like Docker, which is some sort of containerization software. And by containerization software, what we're talking about is the idea that when we're running an application, instead of just running it on your computer, we're going to run it inside of a container on your computer. And each container is going to contain its own configuration. It's going to have certain packages installed. It's going to have certain versions of certain pieces of software. It's going to be configured in exactly the same way. And by leveraging a tool like Docker, you can make sure that so long as you provide the right instructions for how to start up and set up these containers, then if you are working on the application and someone you're working with, some colleague that's also working on the same project, so long as you're using the same instructions for how to set up a Docker container, you're going to be working in the identical environments, that if a package is installed on your computer, in your container, it's going to be installed in your colleague's container as well. And the advantage of this too works with this idea of continuous delivery. When you want to deliver and deploy your application to the internet, you can run your application inside of that exact same container, set up using the exact same set of instructions, so that you don't have to worry about the nightmare headaches of trying to make sure that all the right packages and all the right versions are, in fact, installed on the server. Docker might remind you of the idea of a virtual machine or a VM if you're familiar with that concept. GitHub uses VMs, for instance, when running its GitHub Actions. They are, in fact, different. A VM is effectively running an entire virtual computer with its own virtual operating system and libraries and application running on top of that all inside of your own computer. So a virtual machine ends up taking up a lot of memory, taking up a lot of space. Docker containers, meanwhile, are a bit lighter-weight. They don't have their own operating system. They're all running still on top of the host operating system. But there is this Docker layer in-between that keeps track of all of these various different containers and keeps track of for each container such that every container can have its own separate set of libraries, separate set of binaries, and an application running on top of that. So the advantage then of containerization is that these containers are lighter-weight than having an entire virtual machine. But they can still keep their own environment consistent such that you can feel confident that if the application is working in a Docker container, you can have that Docker container running on your computer, on someone else's computer, on the server to guarantee that the application is going to work the way that you would actually expect it to. And so how exactly do we configure these various different Docker containers? Well, in order to do so, we're going to write what's called a Docker file. So to do this, I'll go ahead and go into airline1. And I'll open up this Docker file. And the Docker file describes the instructions for creating a Docker image where the Docker image represents all of the libraries and other installed items that we might want to have inside of the container. And based on that image, we're able to create a whole bunch of different containers that are all based on that same image, where each container has its own files and can run the web application inside of it. So this Docker file, for example, describes how I might create a container that is going to run my Django web application. So first, I say FROM python:3. This happens to be another Docker image on which I'm going to base these instructions, that this is going to be a Docker image that already contains instructions for installing Python 3, installing other related packages that might be helpful. Oftentimes, when you're writing a Docker file, you'll base it on some existing Docker file that already exists. So here I'm saying go ahead and use Python 3. And now what do I want to do in order to set up this container? Well, I want to copy anything in dot, in my current directory, into the container. And I have to decide, where in the container am I going to store it? Well, there-- I could choose to store it anywhere. But I'll just store it in /usr/src/app, just some particular path that will take me to a directory where I am going to store the application. But you could choose something else entirely. So I copy all of the current files in my current directory. So that will include things like my requirements file, my manage.py file, my applications files, all my settings files. Everything inside of the directory, I would like to copy into the container. Then I'm saying WORKDIR, meaning change my working directory, effectively the same thing as something like CD on your terminal to move into some directory. I would like to set my working directory equal to that same application directory, the application directory inside of the container that now contains all of the files from my application because I copied all of those files into the container. Now once I'm inside of this directory, I need to install all of my requirements. So assuming I've put all my requirements like Django and any other packages that I need inside of a file called requirements.txt, I can just run the command, pip install requirements.txt. And then, finally, inside the Docker file, I specify a command. And this is the command that should run when I start up the container. Everything else is going to happen initially when we're just setting up this Docker image. But when I start up the container and actually want to run my web application, here is the command that should run. And I provide it-- effectively it's like a Python list where each word in the command is separated by a comma, where here I'm saying the command that I would like to run, when you start up this container is python, manage.py, runserver. And here I'm just specifying on what address and what port I would like it to run. And I'm running it on port 8000, for example. But I could choose another port that I would like to run instead. So what's going to happen then is that when I start up this Docker container, it's going to, if it needs to, go through these instructions and make sure that it sets up the container according to these instructions, make sure that we've installed all of the necessary requirements, make sure that we're using Python 3. And anyone using the same Docker file can generate a container that has all the same configuration on it. So we don't have to worry about configuration differences between me and someone else who might not have the exact same computer setup that I do. And the nice thing about this is that it can run on Mac and Windows and Linux. So even people running on different operating systems can still have containers that all have the same configuration, that all work in the same way just to speed up that process. Now so far, when we've been building Django applications, we've been using a SQLite database. SQLite database just being a file that is stored inside of our application. And this file-based database allows us to create tables, insert rows into it, delete rows from it. In most production environments, in most real web applications that are working with many, many users, SQLite is not actually the database that is used. It doesn't scale nearly as well when there are many users all trying to access it concurrently. Oftentimes, in those sorts of situations, you want your database hosted elsewhere on some separate server to be able to handle its own incoming requests and connections. And we talked about a couple of possible databases we could use instead of SQLite, things like MySQL, things like Postgres, or various different SQL-based databases. So imagine now I want to deploy my application. But instead of using SQLite, I would like to use Postgres, for example, as the database server that I would like to run. Well, that would seem to be pretty complicated for me to test on my own because now in addition to running my web application in one server, effectively, I also need another server that's running Postgres, for example, such that I can communicate with that Postgres database instead. And that's going to be even harder for other people to be able to work on as well. Potentially, it might be difficult to get the server to work in that way too. But the nice thing about Docker is that I can run each of these processes in a different container effectively. I can have one container that's running my web application using this Docker file right here. And I can have another container that's just going to run Postgres. And as long as other people also have access to that same container for running Postgres, they can be working in an identical environment to the one that I am working in as well. And so there's also a feature of Docker known as Docker Compose. And what Docker Compose lets us do is allow us to compose multiple different services together, that I would like to run my web application in one container, and I would like to run a Postgres database in another container. But I would like for those containers to be able to talk to each other, to be able to work together whenever I start up the application. So if I'd like to do that, in order to run this application on my computer and have both the web application and Postgres installed, I can create a Docker Compose file which looks like this. Here I'm specifying using version 3 of Docker Compose. Here I specify, again, using a YAML file. Much as in my GitHub workflows were formatted in YAML just as a configuration file, docker-compose.yml is a YAML file that describes all of the various different services that I want to be part of my application, where each service is going to be its own container that could be based on a different Docker image. Here I'm saying that I have two services, one called db for database, one called web for my web application. The database is going to be based on the Postgres Docker image, image that Postgres wrote that I don't have to worry about. Someone else has written the Docker file for how to start up a Postgres container. Here, though, for the web application, that's going to be built based on the Docker file in my current directory, the Docker file that I have written. And then down below, I've just specified that my current directory should correspond to the app directory. And then I've specified when I'm running this on my own computer, I would like port 8000 on the container to correspond to port 8000 on my own computer just so that I can access port 8000 in my browser and access port 8000 inside the container. It just lets my computer actually talk to the container so I can open up the web application in my web browser, for example, and actually see the results of all of this. So here, then, I've created two services, a database and web. So now let's actually try starting up these containers. I'm going to first go into my airline1 directory. And I'm going to say docker-compose up to mean go ahead and start up these services. I'll press Return. And what you'll see is we're going ahead and starting up two services. I'm starting up the database service. And I'm starting up the web service. And now as a result of all of this, I've started up the application. And I started it on port 8000. So if I go to 0.0.0.0 slash 8000 or colon 8000 slash flights, that's going to take me to the Flights page. And now this is running, not just on my own computer, but inside of a Docker container. Now, of course, right now, there are no flights inside of this page because I haven't actually added anything to the database yet. So I could do that if I wanted to. But how do I do that? Well, I needed to go into slash admin to say, like, let me log in and go ahead and create some sample flights. But I don't have a log in yet because I need to create a superuser account. And I can't just like inside of my airline1 directory say, python manage.py createsuperuser the way that I used to because this is running in my terminal on my computer. Whereas, what I really want to do is go into the Docker container and run this command there, inside of the container. So how can I do that? Well, there are various different Docker commands that I can use. docker ps will show me all of the Docker containers that are currently running. So I'll go ahead and shrink this down a little bit. I see two rows, one for each container, one for my Postgres container that's running the database, one for just my web application that's running as well. Each one has a container ID. So I want to go into my web application container in order to run some commands inside of that container. So I'm going to copy its container ID and say, docker exec-- meaning go ahead and execute a command on the container-- dash it will make this interactive. Here's the container ID that I would like to execute a command on. And the command I want to execute is bash, passing the dash l flag, but bash to say, I want to run a bash prompt. I want to be able to interact with a shell so that I can run commands inside of this container. So I press Return. And now what you'll notice is that I am inside of my container in the user source app directory, that directory that contained all of the information about this web application. I type ls. And what I'll see is here, all the files inside of this container now. And now I can say something like python manage.py createsuperuser. And now it's going to let me create a superuser. So I'll create a user inside of my web application called Brian. I'll give it my email address. I'll type in a password. And now we've created a superuser. And you can run other commands here. If you wanted to migrate all of your migrations, I could say python manage.py migrate. And it turns out I've already done that, so I didn't actually have to do it again. But you can run any commands that you can run them on your computer. But now you can run them inside of the Docker container instead. I'm going to press Control D just to log out, get out of the container and get back to my computer. But now I've created a superuser, so I could go ahead and sign in to Django's admin. And now I can begin to manipulate this database, which is a Postgres database running in a separate container. But the nice thing about it is that I can start them both up together just by running something like docker-compose up, for example. So Docker can be quite a powerful tool for allowing us to very quickly ensure that an application is running in the environment that we expect it to be running, to make sure that all of the right libraries are installed, make sure that all the right packages are installed as well, that the configuration between my development environment and the environment that's running on the server are the same as well. So those then were just some of the best practices for how you can go about developing a program now that we have the tools to do so. We have a lot of tools for being able to develop these web applications. But as our programs start to get more complex, it will be increasingly important to test them, make sure that each various different component of our web application behaves the way that it is expected to behave, and then taking advantage, especially in bigger teams, of CI/CD, Continuous Integration, Continuous Delivery to make incremental changes, and make sure each of those incremental changes, in fact, works on the web application. And then CD, Continuous Delivery, to say that rather than wait and then deploy everything all at once, let's deploy things incrementally as well. Let users more quickly get access to the latest features and more quickly find out if something went wrong. We can better identify what it is that went wrong if we've deployed things incrementally rather than waiting a long time in order to do so as well. So these are some of the best practices in modern software application development, not only for web applications but for software more generally. Next time, we'll consider other challenges that might arise as we go about trying to make web applications that are used by more and more users, in particular, taking a look at challenges that will arise in terms of scalability as the programs get bigger and also security of what security vulnerabilities open themselves up as we begin to design our web applications using Python and JavaScript. So more on that next time. And we'll see you then.
Info
Channel: CS50
Views: 47,366
Rating: 4.9893618 out of 5
Keywords: cs50, harvard, computer, science, david, j., malan
Id: WbRDkJ4lPdY
Channel Id: undefined
Length: 93min 58sec (5638 seconds)
Published: Mon Jun 21 2021
Related Videos
Note
Please note that this website is currently a work in progress! Lots of interesting data and statistics to come.