Monday, August 29, 2016

Adding more tests

Now we'll flesh out some of our test cases.  Our test suite is limited, because I'm hitting the limit of Twitter's requests/15 minute period after about 3 test runs.  I wanted to show the types of tests to make, and how the functions might interact with each other.


It's long, but I'm going to post the test file, and then chat about it.


 import logging  
 import pytest  
   
 import conftest  
 from Config import Config  
 from DataObjects.PostStatusRequest import PostStatusRequest  
 from DataObjects.StatusResponse import StatusResponse  
 from DataObjects.StatusResponses import StatusResponses  
 from Helpers.CredsContainer import CredsContainer  
 from WebServicesClients.TwitterStatusesService import TwitterStatusesService  
 from Helpers import TestHelpers  
   
   
 class TestTwitterCRUD:  
   config = None  
   logger = None  
   
   twitter_service = None  
   
   def setup_class(self):  
     self.config = conftest.get_config(pytest.config)  
     self.logger = logging.getLogger(Config.LOGGER_ID)  
     self.twitter_service = TwitterStatusesService(self.config.twitter_api_base_url,  
                            self.config.twitter_auth_creds,  
                            Config.LOGGER_ID)  
     self.logger.info("")  
     self.logger.info("Running TestTwitterCRUD Tests")  
   
   def teardown_class(self):  
     self.logger.info("")  
     self.logger.info("Teardown: Remove all but 2 tweets")  
     # I have found occasionally that instantiated classes inside of tests lose their sense of "self".  
     # It's happened here. No idea why.  
     teardown_response_record = self.get_timeline(self)  
     for x in range(0, len(teardown_response_record.status_list) - 2):  
       self.delete_status_from_timeline(self, teardown_response_record.status_list[x].id, ignore_status=True)  
   
   def test_get_timeline(self):  
     self.logger.info("")  
     self.logger.info("Get timeline test")  
     # Post something to the timeline to find later  
     post_response_record_text = self.post_to_timeline().text  
   
     response_record = self.get_timeline()  
     assert len(response_record.status_list) > 0  
     assert response_record.find_record_with_status_text(post_response_record_text), \  
       "Could not find record with posted text in timeline"  
   
   def test_post_to_timeline(self):  
     tweet_text = "Test Post to timeline: {}".format(TestHelpers.make_random_string(6))  
     self.logger.info("")  
     self.logger.info("Post to timeline test")  
     response_record = self.post_to_timeline(tweet_text)  
     assert response_record.text == tweet_text  
   
     # Retrieve timeline, and verify our post is in there  
     response_record = self.get_timeline()  
     assert response_record.find_record_with_status_text(tweet_text), \  
       "Could not find record with posted text in timeline"  
   
   def test_delete_from_timeline(self):  
     # Post a status so we can delete it.  
     post_tweet_response = self.post_to_timeline()  
     assert post_tweet_response.id is not None  
   
     # delete record  
     deleted_response = self.delete_status_from_timeline(post_tweet_response.id)  
     assert deleted_response.id == post_tweet_response.id  
   
     # get timeline and make sure deleted tweet is gone  
     timeline_response = self.get_timeline()  
     assert timeline_response.find_record_by_id(deleted_response.id) is None  
   
   def test_cannot_get_timeline_with_invalid_consumer_key(self):  
     creds = CredsContainer(self.config.twitter_auth_creds.consumer_key,  
                 "Bar",  
                 self.config.twitter_auth_creds.access_token,  
                 self.config.twitter_auth_creds.access_token_secret)  
     bad_twitter_service = TwitterStatusesService(self.config.twitter_api_base_url,  
                            creds, Config.LOGGER_ID)  
     response = bad_twitter_service.get_home_timeline()  
     TestHelpers.verify_http_response(response, 401, "Get timeline with invalid consumer key", True)  
   
   def test_cannot_get_timeline_with_invalid_access_token(self):  
     creds = CredsContainer(self.config.twitter_auth_creds.consumer_key,  
                 self.config.twitter_auth_creds.access_token_secret,  
                 self.config.twitter_auth_creds.access_token,  
                 "Bar")  
     bad_twitter_service = TwitterStatusesService(self.config.twitter_api_base_url,  
                            creds, Config.LOGGER_ID)  
     response = bad_twitter_service.get_home_timeline()  
     TestHelpers.verify_http_response(response, 401, "Get timeline with invalid consumer key", True)  
   
   def test_cannot_delete_tweet_that_has_been_deleted(self):  
     # Post a status so we can delete it.  
     post_tweet_response = self.post_to_timeline()  
     assert post_tweet_response.id is not None  
   
     # delete record  
     deleted_response = self.delete_status_from_timeline(post_tweet_response.id)  
     assert deleted_response.id == post_tweet_response.id  
   
     # delete record again  
     response = self.twitter_service.delete_status(post_tweet_response.id)  
     TestHelpers.verify_http_response(response, 404, "delete tweet that has already been deleted", True)  
   
   def test_cannot_post_status_without_status_text(self):  
     tweet_text = ""  
     self.logger.info("")  
     self.logger.info("Cannot post status without status text")  
     payload = PostStatusRequest(tweet_text)  
     response = self.twitter_service.post_tweet(payload.create_query_string())  
     TestHelpers.verify_http_response(response, 403, "Cannot post status without status text")  
   
   def test_sql_injection_in_status(self):  
     # Wee need to make our tweet unique.  
     tweet_text = "select * from users --{}".format(TestHelpers.make_random_string(6))  
     self.logger.info("")  
     self.logger.info("test sql inject in status")  
     response_record = self.post_to_timeline(tweet_text)  
     assert response_record.text == tweet_text  
   
     # Retrieve timeline, and verify our post is in there  
     response_record = self.get_timeline()  
     assert response_record.find_record_with_status_text(tweet_text), \  
       "Could not find record with posted text in timeline"  
   
   def get_timeline(self):  
     response = self.twitter_service.get_home_timeline()  
     TestHelpers.verify_http_response(response, 200, "Get Twitter Account Timeline")  
     response_record = StatusResponses.list_from_dict(response.response_json)  
     return response_record  
   
   def post_to_timeline(self, tweet_text=None):  
     # This will add a status to the timeline  
     if tweet_text is None:  
       tweet_text = "Test Status {}".format(TestHelpers.make_random_string(6))  
     payload = PostStatusRequest(tweet_text)  
     self.logger.debug("Post status payload = {}".format(payload.create_query_string()))  
     response = self.twitter_service.post_tweet(payload.create_query_string())  
     TestHelpers.verify_http_response(response, 200, "Post to Timeline")  
     response_record = StatusResponse.from_dict(response.response_json)  
     return response_record  
   
   def delete_status_from_timeline(self, status_id, ignore_status=False):  
     response = self.twitter_service.delete_status(status_id)  
     if ignore_status is False:  
       TestHelpers.verify_http_response(response, 200, "Delete Status from timeline")  
     else:  
       # if we did ignore status, do not parse the response.  
       return None  
     response_record = StatusResponse.from_dict(response.response_json)  
     return response_record  
   

The Cart before the horse problem

The first thing you will notice is there's a bunch of methods below the test cases that do most of the work.  What I have encountered often is tests that require other api calls to be complete in order to run.  I used to create elaborate "stepped" tests to make it all work, such:

  1. Post to the timeline.
  2. Get posts from the timeline
  3. Delete posts in the timeline
These tests had to run in order, and if the first one failed the other 2 tests couldn't run.  While you may want to do this to reduce the number of api calls made it's an anti pattern, and generally not what you want.  The only exception to this rule I would say are tests that take a very long time to run.  We'll talk about long running tests in a future blog post, but sometime you have no choice.

What I decided to do, in order to not duplicate code is to create methods to do the basic operations I would call repeatedly.  Most of the time I would get a combination of api calls that would be called only once, and some that are called many times.  So if I wound up calling it twice I would extract it into a method.

The tests that are actually testing the functionality would use the same method calls, but would be more stringent in setting up data, and also checking the resulting data for correctness. Let's look at post_timeline again.

 def test_post_to_timeline(self):  
     tweet_text = "Test Post to timeline: {}".format(TestHelpers.make_random_string(6))  
     self.logger.info("")  
     self.logger.info("Post to timeline test")  
     response_record = self.post_to_timeline(tweet_text)  
     assert response_record.text == tweet_text  
   
     # Retrieve timeline, and verify our post is in there  
     response_record = self.get_timeline()  
     assert response_record.find_record_with_status_text(tweet_text), \  
       "Could not find record with posted text in timeline"  

This test does the following:

  1. Make calls to post to timeline so that we have something to look for when we call get_timeline.
  2. Retrieve the timeline.
  3. Check the timeline records for one containing the tweet text.
  4. Cleanup is done in the teardown code.
It's more api calls, but the actual runtime is quite short.  

A walk through the test cases


Let's go through the other test cases (and setup and teardown).


  • setup_class
    • We read our config object
    • Get a reference to our loggger
    • Create the twitter status service we will use for most of our calls.
  • teardown_class
    • Retrieve all records from timeline
    • Delete all records from timeline but 2.
  • test_get_timeline
    • Test is described in previous section.
  • test_post_to_timeline
    • We post a tweet to the timeline, with some random characters in the tweet text so that when we retrieve it we will know it's this tweet.
    • Verify the response to post to timeline, verifying the returned record is the correct one.
    • Use get_timeline to get all tweets from the timeline.
    • Verify the timeline contains a record with the tweet text.
  • test_delete_from_timeline
    • Post a new tweet to the user's timeline.
    • Save the ID of the tweet.
    • Delete the tweet that we just created.
    • Get the timeline again.
    • Verify that the ID of the tweet we created earlier does NOT exist in the timeline.
  • test_cannot_get_timeline_with_invalid_consumer_key
    • This is a negative test to ensure you cannot post with invalid oauth credentials.
    • Make a new instance of the twitter service, with invalid consumer secret.
    • Attempt to get timeline
    • Verify call to get timeline responds with a 401 error.
  • test_cannot_get_timeline_with_invalid_access_token
    • This is a negative test to ensure you cannot post with invalid oauth credentials.
    • Make a new instance of the twitter service, with invalid token secret.
    • Attempt to get timeline
    • Verify call to get timeline responds with a 401 error.
  • test_cannot_delete_tweet_that_has_been_deleted
    • Post a new tweet to the timeline.
    • Delete the tweet.
    • Delete the same tweet again.
    • Verify we get a 404 for record not found.
  • test_cannot_post_status_without_status_text
    • Try to post a tweet without status text
    • Verify you get a 403 error when you attempt to do this.
    • Ordinarily with a negative test case I would try to pull the timeline again, and make sure no record was created.  However in this case since we would key off the tweet text, which is empty, there's not much else we can do for verification.
  • test_sql_injection_in_status
    • Create a tweet with a sql select statement in it.  Note that to make the tweet unique we still need some random characters at the end of it, so I include them as a comment to the SQL line.
    • Of course in this instance I can only guess if there is a "users" table
    • I could also try mongo db statment, and lots of other combinations, Depending on how sensitive the data being stored is, and if this is a public or private website would determine I created more 
Naturally if I was testing this API I would create more test cases.  But I think this is a good sampling of test cases to show how this framework would work in a real world scenerio.  I will probably add a test case to verify the returned user data, as that would show how to use a nested data class.

No comments:

Post a Comment